From e5f67712ecddaab1501029bcf3fb73fc55c25222 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 7 Nov 2025 15:19:09 +0100 Subject: [PATCH] add getGitSuperProjectRoot API --- .../src/vcs/__tests__/gitUtils.test.ts | 137 ++++++++++++++++++ packages/docusaurus-utils/src/vcs/gitUtils.ts | 54 ++++++- 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts index f85fc06638..c2f383971a 100644 --- a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts @@ -16,6 +16,7 @@ import { getGitLastUpdate, getGitCreation, getGitRepoRoot, + getGitSuperProjectRoot, } from '../gitUtils'; class Git { @@ -95,6 +96,54 @@ class Git { {env: {GIT_COMMITTER_DATE: `${date}T00:00:00Z`}}, ); } + + async commitFile( + filePath: string, + { + fileContent, + commitMessage, + commitDate, + commitAuthor, + }: { + fileContent?: string; + commitMessage?: string; + commitDate?: string; + commitAuthor?: string; + } = {}, + ): Promise { + await fs.ensureDir(path.join(this.dir, path.dirname(filePath))); + await fs.writeFile( + path.join(this.dir, filePath), + fileContent ?? `Content of ${filePath}`, + ); + await this.commit( + commitMessage ?? `Create ${filePath}`, + commitDate ?? '2020-06-19', + commitAuthor ?? 'Seb ', + ); + } + + async addSubmodule(name: string, repoPath: string): Promise { + return this.runOptimisticGitCommand('git', [ + '-c protocol.file.allow=always', + 'submodule', + 'add', + repoPath, + name, + ]); + } + + async defineSubmodules(submodules: {[name: string]: string}): Promise { + for (const entry of Object.entries(submodules)) { + await this.addSubmodule(entry[0], entry[1]); + } + await this.runOptimisticGitCommand('git', [ + 'submodule', + 'update', + '--init', + '--recursive', + ]); + } } async function createGitRepoEmpty(): Promise<{repoDir: string; git: Git}> { @@ -348,3 +397,91 @@ describe('getGitRepoRoot', () => { ); }); }); + +describe('submodules APIs', () => { + async function initTestRepo() { + const superproject = await createGitRepoEmpty(); + await superproject.git.commitFile('README.md'); + await superproject.git.commitFile('website/docs/myDoc.md'); + + const submodule1 = await createGitRepoEmpty(); + await submodule1.git.commitFile('file1.txt'); + + const submodule2 = await createGitRepoEmpty(); + await submodule2.git.commitFile('subDir/file2.txt'); + + await superproject.git.defineSubmodules({ + 'submodules/submodule1': submodule1.repoDir, + 'submodules/submodule2': submodule2.repoDir, + }); + + return {superproject, submodule1, submodule2}; + } + + describe('getGitSuperProjectRoot', () => { + it('returns superproject dir for cwd=superproject', async () => { + const repo = await initTestRepo(); + const cwd = path.join(repo.superproject.repoDir); + await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual( + repo.superproject.repoDir, + ); + }); + + it('returns superproject dir for cwd=superproject/submodules', async () => { + const repo = await initTestRepo(); + const cwd = path.join(repo.superproject.repoDir, 'submodules'); + await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual( + repo.superproject.repoDir, + ); + }); + + it('returns superproject dir for cwd=superproject/website/docs', async () => { + const repo = await initTestRepo(); + const cwd = path.join(repo.superproject.repoDir, 'website/docs'); + await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual( + repo.superproject.repoDir, + ); + }); + + it('returns superproject dir for cwd=submodule1', async () => { + const repo = await initTestRepo(); + const cwd = path.join(repo.superproject.repoDir, 'submodules/submodule1'); + await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual( + repo.superproject.repoDir, + ); + }); + + it('returns superproject dir for cwd=submodule2', async () => { + const repo = await initTestRepo(); + const cwd = path.join(repo.superproject.repoDir, 'submodules/submodule2'); + await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual( + repo.superproject.repoDir, + ); + }); + + it('returns superproject dir for cwd=submodule2/subDir', async () => { + const repo = await initTestRepo(); + const cwd = path.join( + repo.superproject.repoDir, + 'submodules/submodule2/subDir', + ); + await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual( + repo.superproject.repoDir, + ); + }); + + it('rejects for cwd of untracked dir', async () => { + const cwd = await os.tmpdir(); + + // Do we really want this to throw? + // Not sure, and Git doesn't help us failsafe and return null... + await expect(getGitSuperProjectRoot(cwd)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Couldn't find the git superproject root directory + Failure while running \`git rev-parse --show-superproject-working-tree\` from cwd="" + The command executed throws an error: Command failed with exit code 128: git rev-parse --show-superproject-working-tree + fatal: not a git repository (or any of the parent directories): .git" + `); + }); + }); +}); diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index b3cf6e81c1..55f9e7a2dc 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -265,9 +265,9 @@ export async function getGitCreation( export async function getGitRepoRoot(cwd: string): Promise { const createErrorMessageBase = () => { return `Couldn't find the git repository root directory -Running ${logger.code('git rev-parse --show-toplevel')} from cwd=${logger.path( - cwd, - )})`; +Failure while running ${logger.code( + 'git rev-parse --show-toplevel', + )} from cwd=${logger.path(cwd)}`; }; const result = await execa('git', ['rev-parse', '--show-toplevel'], { @@ -276,7 +276,7 @@ Running ${logger.code('git rev-parse --show-toplevel')} from cwd=${logger.path( // We enter this rejection when cwd is not a dir for example throw new Error( `${createErrorMessageBase()} -The command executed throws an error`, +The command executed throws an error: ${error.message}`, {cause: error}, ); }); @@ -292,3 +292,49 @@ The command returned exit code ${logger.code(result.exitCode)}: ${logger.subdue( return fs.realpath.native(result.stdout.trim()); } + +// A Git "superproject" is a Git repository that contains submodules +// See https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt---show-superproject-working-tree +// See https://git-scm.com/book/en/v2/Git-Tools-Submodules +export async function getGitSuperProjectRoot( + cwd: string, +): Promise { + const createErrorMessageBase = () => { + return `Couldn't find the git superproject root directory +Failure while running ${logger.code( + 'git rev-parse --show-superproject-working-tree', + )} from cwd=${logger.path(cwd)}`; + }; + + const result = await execa( + 'git', + ['rev-parse', '--show-superproject-working-tree'], + { + cwd, + }, + ).catch((error) => { + // We enter this rejection when cwd is not a dir for example + throw new Error( + `${createErrorMessageBase()} +The command executed throws an error: ${error.message}`, + {cause: error}, + ); + }); + + if (result.exitCode !== 0) { + throw new Error( + `${createErrorMessageBase()} +The command returned exit code ${logger.code(result.exitCode)}: ${logger.subdue( + result.stderr, + )}`, + ); + } + + const output = result.stdout.trim(); + // this command only works when inside submodules + // otherwise it doesn't return anything when we are inside the main repo + if (output) { + return output; + } + return getGitRepoRoot(cwd); +}