From 688d125ae74dcc408d18aeab58a53ecf46fab522 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 7 Nov 2025 16:36:04 +0100 Subject: [PATCH] add tests for getGitRepositoryFilesInfo --- jest/snapshotPathNormalizer.ts | 2 +- .../src/vcs/__tests__/gitUtils.test.ts | 170 +++++++++++++----- packages/docusaurus-utils/src/vcs/gitUtils.ts | 123 +++++++++++++ 3 files changed, 252 insertions(+), 43 deletions(-) diff --git a/jest/snapshotPathNormalizer.ts b/jest/snapshotPathNormalizer.ts index a0e9a3f4d3..b8a5ec33ab 100644 --- a/jest/snapshotPathNormalizer.ts +++ b/jest/snapshotPathNormalizer.ts @@ -82,7 +82,7 @@ function normalizePaths(value: T): T { (val) => val.split(cwdReal).join(''), (val) => val.split(cwd).join(''), - // Replace home directory with + // Replace temp directory with (val) => val.split(tempDirReal).join(''), (val) => val.split(tempDir).join(''), diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts index 01f93631d4..2f4e65066c 100644 --- a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts @@ -19,6 +19,7 @@ import { getGitSuperProjectRoot, getGitSubmodulePaths, getGitAllRepoRoots, + getGitRepositoryFilesInfo, } from '../gitUtils'; class Git { @@ -186,7 +187,7 @@ describe('commit info APIs', () => { await git.commit( 'Update test.txt again, create moved.txt', '2020-09-13', - 'Caroline ', + 'Robert ', ); await fs.move( @@ -197,7 +198,7 @@ describe('commit info APIs', () => { await git.commit( 'Rename moved.txt to dest.txt', '2020-11-13', - 'Josh-Cena ', + 'Seb ', ); await fs.writeFile(path.join(repoDir, 'untracked.txt'), "I'm untracked"); @@ -206,40 +207,6 @@ describe('commit info APIs', () => { } describe('getFileCommitDate', () => { - it('returns earliest commit date', async () => { - const repoDir = await createGitRepoTestFixture(); - - await expect( - getFileCommitDate(path.join(repoDir, 'test.txt'), {}), - ).resolves.toEqual({ - date: new Date('2020-06-19'), - timestamp: new Date('2020-06-19').getTime(), - }); - await expect( - getFileCommitDate(path.join(repoDir, 'dest.txt'), {}), - ).resolves.toEqual({ - date: new Date('2020-09-13'), - timestamp: new Date('2020-09-13').getTime(), - }); - }); - - it('returns latest commit date', async () => { - const repoDir = await createGitRepoTestFixture(); - - await expect( - getFileCommitDate(path.join(repoDir, 'test.txt'), {age: 'newest'}), - ).resolves.toEqual({ - date: new Date('2020-09-13'), - timestamp: new Date('2020-09-13').getTime(), - }); - await expect( - getFileCommitDate(path.join(repoDir, 'dest.txt'), {age: 'newest'}), - ).resolves.toEqual({ - date: new Date('2020-11-13'), - timestamp: new Date('2020-11-13').getTime(), - }); - }); - it('returns latest commit date with author', async () => { const repoDir = await createGitRepoTestFixture(); @@ -261,7 +228,7 @@ describe('commit info APIs', () => { ).resolves.toEqual({ date: new Date('2020-09-13'), timestamp: new Date('2020-09-13').getTime(), - author: 'Caroline', + author: 'Robert', }); }); @@ -276,7 +243,7 @@ describe('commit info APIs', () => { ).resolves.toEqual({ date: new Date('2020-09-13'), timestamp: new Date('2020-09-13').getTime(), - author: 'Caroline', + author: 'Robert', }); await expect( getFileCommitDate(path.join(repoDir, 'dest.txt'), { @@ -286,7 +253,7 @@ describe('commit info APIs', () => { ).resolves.toEqual({ date: new Date('2020-11-13'), timestamp: new Date('2020-11-13').getTime(), - author: 'Josh-Cena', + author: 'Seb', }); }); @@ -326,7 +293,7 @@ describe('commit info APIs', () => { }); await expect(getGitLastUpdate(filePath)).resolves.toEqual({ - author: 'Caroline', + author: 'Robert', timestamp: new Date('2020-09-13').getTime(), }); }); @@ -336,11 +303,11 @@ describe('commit info APIs', () => { const filePath = path.join(repoDir, 'dest.txt'); await expect(getGitCreation(filePath)).resolves.toEqual({ - author: 'Caroline', + author: 'Robert', timestamp: new Date('2020-09-13').getTime(), }); await expect(getGitLastUpdate(filePath)).resolves.toEqual({ - author: 'Josh-Cena', + author: 'Seb', timestamp: new Date('2020-11-13').getTime(), }); }); @@ -368,6 +335,45 @@ describe('commit info APIs', () => { `"An error occurred when trying to get the last update date"`, ); }); + + it('returns files info', async () => { + const repoDir = await createGitRepoTestFixture(); + await expect(getGitRepositoryFilesInfo(repoDir)).resolves + .toMatchInlineSnapshot(` + Map { + "dest.txt" => { + "creation": { + "author": "Seb", + "timestamp": 1605225600000, + }, + "lastUpdate": { + "author": "Seb", + "timestamp": 1605225600000, + }, + }, + "moved.txt" => { + "creation": { + "author": "Robert", + "timestamp": 1599955200000, + }, + "lastUpdate": { + "author": "Robert", + "timestamp": 1599955200000, + }, + }, + "test.txt" => { + "creation": { + "author": "Caroline", + "timestamp": 1592524800000, + }, + "lastUpdate": { + "author": "Robert", + "timestamp": 1599955200000, + }, + }, + } + `); + }); }); }); @@ -625,4 +631,84 @@ describe('submodules APIs', () => { ); }); }); + + describe('getGitRepositoryFilesInfo', () => { + it('for superproject', async () => { + const repo = await initTestRepo(); + const cwd = path.join(repo.superproject.repoDir); + await expect(getGitRepositoryFilesInfo(cwd)).resolves + .toMatchInlineSnapshot(` + Map { + "website/docs/myDoc.md" => { + "creation": { + "author": "Seb", + "timestamp": 1592524800000, + }, + "lastUpdate": { + "author": "Seb", + "timestamp": 1592524800000, + }, + }, + "README.md" => { + "creation": { + "author": "Seb", + "timestamp": 1592524800000, + }, + "lastUpdate": { + "author": "Seb", + "timestamp": 1592524800000, + }, + }, + } + `); + }); + + it('for submodule1', async () => { + const repo = await initTestRepo(); + const cwd = path.join( + repo.superproject.repoDir, + 'submodules', + 'submodule1', + ); + await expect(getGitRepositoryFilesInfo(cwd)).resolves + .toMatchInlineSnapshot(` + Map { + "file1.txt" => { + "creation": { + "author": "Seb", + "timestamp": 1592524800000, + }, + "lastUpdate": { + "author": "Seb", + "timestamp": 1592524800000, + }, + }, + } + `); + }); + + it('for submodule2', async () => { + const repo = await initTestRepo(); + const cwd = path.join( + repo.superproject.repoDir, + 'submodules', + 'submodule2', + ); + await expect(getGitRepositoryFilesInfo(cwd)).resolves + .toMatchInlineSnapshot(` + Map { + "subDir/file2.txt" => { + "creation": { + "author": "Seb", + "timestamp": 1592524800000, + }, + "lastUpdate": { + "author": "Seb", + "timestamp": 1592524800000, + }, + }, + } + `); + }); + }); }); diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index d2409385ed..b25eeed384 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -412,3 +412,126 @@ export async function getGitAllRepoRoots(cwd: string): Promise { ); } } + +// Useful information about a file tracked in a Git repository +type GitFileInfo = { + creation: GitCommitInfo; + lastUpdate: GitCommitInfo; +}; + +// A map of all the files tracked in a Git repository +type GitFilesInfo = Map; + +// Logic inspired from Astro Starlight: +// See https://bsky.app/profile/bluwy.me/post/3lyihod6qos2a +// See https://github.com/withastro/starlight/blob/c417f1efd463be63b7230617d72b120caed098cd/packages/starlight/utils/git.ts#L58 +export async function getGitRepositoryFilesInfo( + filePath: string, +): Promise { + const repoRoot = await getGitRepoRoot(filePath); + + // git -c log.showSignature=false log --format=t:%ct,a:%an --name-status + const result = await execa( + 'git', + [ + // Do not include GPG signature in the log output + // See https://github.com/facebook/docusaurus/pull/10022 + '-c', + 'log.showSignature=false', + // The git command we want to run + 'log', + // Format each history entry as t: + '--format=t:%ct,a:%an', + // In each entry include the name and status for each modified file + '--name-status', + + // For creation info, should we use --follow --find-renames=100% ??? + ], + { + cwd: repoRoot, + encoding: 'utf-8', + // TODO use streaming to avoid a large buffer + // See https://github.com/withastro/starlight/issues/3154 + maxBuffer: 20 * 1024 * 1024, + }, + ); + + if (result.exitCode !== 0) { + throw new Error( + `Docusaurus failed to run the 'git log' to retrieve tracked files last update date/author. +The command exited with code ${result.exitCode}: ${result.stderr}`, + ); + } + + const logLines = result.stdout.split('\n'); + + const now = Date.now(); + + // TODO not fail-fast + let runningDate = now; + let runningAuthor = 'N/A'; + const runningMap: GitFilesInfo = new Map(); + + for (const logLine of logLines) { + if (logLine.startsWith('t:')) { + // t:,a: + const [timestampStr, authorStr] = logLine.split(',') as [string, string]; + const timestamp = Number.parseInt(timestampStr.slice(2), 10) * 1000; + const author = authorStr.slice(2); + + runningDate = timestamp; + runningAuthor = author; + } + + // TODO the code below doesn't handle delete/move/rename operations properly + // it returns files that no longer exist in the repo (deleted/moved) + + // - Added files take the format `A\t` + // - Modified files take the format `M\t` + // - Deleted files take the format `D\t` + // - Renamed files take the format `R\t\t` + // - Copied files take the format `C\t\t` + // The name of the file as of the commit being processed is always + // the last part of the log line. + const tabSplit = logLine.lastIndexOf('\t'); + if (tabSplit === -1) { + continue; + } + const relativeFile = logLine.slice(tabSplit + 1); + + const currentFileInfo = runningMap.get(relativeFile); + + const currentCreationTime = currentFileInfo?.creation.timestamp || now; + const newCreationTime = Math.min(currentCreationTime, runningDate); + const newCreation: GitCommitInfo = + !currentFileInfo || newCreationTime !== currentCreationTime + ? {timestamp: newCreationTime, author: runningAuthor} + : currentFileInfo.creation; + + const currentLastUpdateTime = currentFileInfo?.lastUpdate.timestamp || 0; + const newLastUpdateTime = Math.max(currentLastUpdateTime, runningDate); + const newLastUpdate: GitCommitInfo = + !currentFileInfo || newLastUpdateTime !== currentLastUpdateTime + ? {timestamp: newLastUpdateTime, author: runningAuthor} + : currentFileInfo.lastUpdate; + + runningMap.set(relativeFile, { + creation: newCreation, + lastUpdate: newLastUpdate, + }); + } + + /* + function transformMapEntry( + entry: [string, GitFileInfo], + ): [string, GitFileInfo] { + // We just resolve the Git paths that are relative to the repo root + return [resolve(repoRoot, entry[0]), entry[1]]; + } + + return new Map(Array.from(runningMap.entries()).map(transformMapEntry)); + + */ + + return runningMap; +}