Skip to content
Open
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
28 changes: 28 additions & 0 deletions packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getGitSubmodulePaths,
getGitAllRepoRoots,
getGitRepositoryFilesInfo,
fromGitBashPath,
} from '../gitUtils';
import {createVcsGitEagerConfig} from '../vcsGitEager';

Expand Down Expand Up @@ -448,6 +449,33 @@ describe('getGitRepoRoot', () => {
});
});

describe('fromGitBashPath', () => {
it('converts Git Bash / MSYS mount paths to Windows paths', () => {
expect(fromGitBashPath('/p/projets/my-repo')).toBe('P:\\projets\\my-repo');
expect(fromGitBashPath('/c/dev/docusaurus')).toBe('C:\\dev\\docusaurus');
expect(fromGitBashPath('/d/a/b/c')).toBe('D:\\a\\b\\c');
});

it('upper-cases the drive letter', () => {
expect(fromGitBashPath('/e/folder')).toBe('E:\\folder');
});

it('converts a repository located at a drive root', () => {
expect(fromGitBashPath('/c')).toBe('C:\\');
expect(fromGitBashPath('/c/')).toBe('C:\\');
});

it('leaves regular paths unchanged', () => {
// Standard Git for Windows output (drive letter + forward slashes)
expect(fromGitBashPath('C:/dev/docusaurus')).toBe('C:/dev/docusaurus');
// POSIX paths that are not single-letter mount paths
expect(fromGitBashPath('/home/user/repo')).toBe('/home/user/repo');
expect(fromGitBashPath('/usr/local')).toBe('/usr/local');
expect(fromGitBashPath('/usr')).toBe('/usr');
expect(fromGitBashPath('/cc')).toBe('/cc');
});
});

describe('submodules APIs', () => {
async function initTestRepo() {
const superproject = await createGitRepoEmpty();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {execa, type Result} from 'execa';
import {fs} from '@docusaurus/fs';
import {getGitRepoRoot, getGitSuperProjectRoot} from '../gitUtils';

// This file mocks execa and the filesystem, so it lives apart from the
// integration tests in gitUtils.test.ts (which run against real Git repos).
// Vitest scopes module mocks per file, so the two do not interfere.
vi.mock('execa', () => ({
execa: vi.fn(),
execaSync: vi.fn(() => ({exitCode: 0})),
}));

vi.mock('@docusaurus/fs', () => ({
fs: {realpath: vi.fn(async (p: string) => p)},
pathExists: vi.fn(),
}));

describe('Git root resolvers normalize Git Bash paths on Windows', () => {
const originalPlatform = process.platform;

beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
});
});

afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});

it('getGitRepoRoot passes a normalized Windows path to fs.realpath', async () => {
vi.mocked(execa).mockResolvedValue({
stdout: '/c/Users/foo/project',
} as unknown as Result);

await getGitRepoRoot('/any/cwd');

// fs.realpath would receive '/c/Users/foo/project' (and this would fail) if
// normalizeGitPath were removed from the resolver.
expect(fs.realpath).toHaveBeenCalledWith('C:\\Users\\foo\\project');
});

it('getGitSuperProjectRoot passes a normalized Windows path to fs.realpath', async () => {
vi.mocked(execa).mockResolvedValue({
stdout: '/d/work/super',
} as unknown as Result);

await getGitSuperProjectRoot('/any/cwd');

expect(fs.realpath).toHaveBeenCalledWith('D:\\work\\super');
});

it('leaves POSIX Git paths unchanged on non-Windows platforms', async () => {
// A `/c/project` path is a valid absolute path on Linux and must be passed
// through untouched; this fails if the win32 guard is removed from
// normalizeGitPath.
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
});
vi.mocked(execa)
.mockResolvedValueOnce({stdout: '/c/project'} as unknown as Result)
.mockResolvedValueOnce({stdout: '/c/project'} as unknown as Result);

await getGitRepoRoot('/any/cwd');
await getGitSuperProjectRoot('/any/cwd');

expect(fs.realpath).toHaveBeenNthCalledWith(1, '/c/project');
expect(fs.realpath).toHaveBeenNthCalledWith(2, '/c/project');
});
});
27 changes: 25 additions & 2 deletions packages/docusaurus-utils/src/vcs/gitUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,29 @@ export async function isGitInsideWorktree(cwd: string): Promise<boolean> {
}
}

// Git Bash / MSYS on Windows reports paths as POSIX "mount" paths such as
// `/c/Users/foo` instead of `C:\Users\foo`. Node's path APIs treat the
// leading `/c` as a directory under the current drive root (resolving to
// `C:\c\Users\foo`), which then fails with ENOENT. Convert such a path back
// to a real Windows path. Exported for testing.
export function fromGitBashPath(gitPath: string): string {
// Match `/c`, `/c/`, or `/c/sub/path`. The subpath is optional so a
// repository located at a drive root (reported by Git Bash as `/c`) maps to
// `C:\` instead of being left unchanged.
const match = /^\/(?<drive>[a-z])(?:\/(?<subpath>.*))?$/i.exec(gitPath);
if (!match) {
return gitPath;
}
const subpath = match.groups!.subpath ?? '';
return `${match.groups!.drive!.toUpperCase()}:\\${subpath.replaceAll('/', '\\')}`;
}

// Normalize a path returned by Git before handing it to the filesystem. Only
// needed on Windows, where Git Bash may return MSYS mount paths (see above).
function normalizeGitPath(gitPath: string): string {
return process.platform === 'win32' ? fromGitBashPath(gitPath) : gitPath;
}

export async function getGitRepoRoot(cwd: string): Promise<string> {
const result = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
Expand All @@ -269,7 +292,7 @@ The command executed throws an error: ${error.message}`,
);
});

return fs.realpath(result.stdout.trim());
return fs.realpath(normalizeGitPath(result.stdout.trim()));
}

// A Git "superproject" is a Git repository that contains submodules
Expand Down Expand Up @@ -300,7 +323,7 @@ The command executed throws an error: ${error.message}`,
// this command only works when inside submodules
// otherwise it doesn't return anything when we are inside the main repo
if (output) {
return fs.realpath(output);
return fs.realpath(normalizeGitPath(output));
}
return getGitRepoRoot(cwd);
}
Expand Down