Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Preserve unmanaged Git hooks during install/update, detect hook ownership conflicts, and diagnose Git LFS wrapper incompatibility with reused checkout workspaces.",
"type": "patch"
}
],
"packageName": "@microsoft/rush"
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,37 @@
#!/bin/sh
#
# This is an example Git hook for use with Rush. To enable this hook, rename this file
# to "commit-msg" and then run "rush install", which will copy it from common/git-hooks
# to the .git/hooks folder.
# to "commit-msg" and then run "rush install", which will install a delegate in the
# Git hooks folder that invokes common/git-hooks/commit-msg.
#
# HOOK OWNERSHIP AND GIT LFS
#
# Rush preserves hooks installed by other tools. If a common/git-hooks file has the
# same name as an unmanaged installed hook, Rush reports an error without changing
# any hooks. Review and migrate custom logic before removing a conflicting hook.
# --bypass-policy skips hook installation on a conflict; it does not overwrite hooks.
# Rush refreshes its unmodified delegates (including those from older Rush versions)
# and removes obsolete non-LFS delegates. Locally edited delegates are preserved.
#
# Git LFS owns pre-push, post-checkout, post-commit, and post-merge. If you do not
# need custom implementations of these hooks, leave these names OUT of common/git-hooks
# and run "git lfs install --local". Rush will preserve the canonical LFS hooks;
# placeholders are not needed to enable LFS uploads.
#
# If you opt into a Rush-managed hook with one of these names, the delegate invokes
# the repository hook and then Git LFS (if installed). Rush warns that these wrappers
# are NOT accepted by "git lfs install --local". In particular, Azure Pipelines LFS
# checkout can fail on a reused agent before any repository script runs. Custom Rush
# hooks with these names require a checkout flow that does not reinitialize LFS hooks.
#
# To recover an existing workspace, first remove unnecessary LFS hook placeholders
# from common/git-hooks. Then, before the next checkout, remove only the corresponding
# Rush-generated delegates from the Git hooks folder and run "git lfs install --local".
# This may require agent maintenance or a fresh workspace. Rush deliberately preserves
# obsolete LFS delegates with a warning until you do this, to avoid disabling uploads.
# Do not use "git lfs install --force" if it would overwrite legitimate custom hooks.
# Older Rush versions emptied the entire hooks folder, so upgrading alone cannot
# restore LFS hooks already deleted by an earlier install: initialize LFS again.
#
# TO LEARN MORE ABOUT GIT HOOKS
#
Expand Down
209 changes: 209 additions & 0 deletions libraries/rush-lib/src/logic/GitHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import {
AlreadyReportedError,
FileSystem,
NewlineKind,
Path,
PosixModeBits
} from '@rushstack/node-core-library';
import { Colorize, type ITerminal } from '@rushstack/terminal';

import type { RushConfiguration } from '../api/RushConfiguration';
import { Git } from './Git';
import { RushConstants } from './RushConstants';

const gitLfsHooks: ReadonlySet<string> = new Set(['post-checkout', 'post-commit', 'post-merge', 'pre-push']);
const generatedHookMarker: string = '# Generated by Rush. Do not edit.\n';

/**
* Installs delegates without taking ownership of hooks installed by other tools.
*/
export class GitHooks {
public static async installAsync(
rushConfiguration: RushConfiguration,
terminal: ITerminal,
bypassPolicy: boolean
): Promise<void> {
const hookSource: string = path.join(rushConfiguration.commonFolder, 'git-hooks');
const git: Git = new Git(rushConfiguration);
const hookDestination: string | undefined = git.getHooksFolder();
if (!hookDestination) {
return;
}

// Only install files that look like Git hook names, ignoring ".sample" files.
const hookFilenames: string[] = FileSystem.exists(hookSource)
? FileSystem.readFolderItemNames(hookSource).filter((name) => /^[a-z-]+$/.test(name))
: [];

if (!(await git.getIsHooksPathDefaultAsync())) {
// Do not clean up the default hooks folder when another hook manager is active.
if (hookFilenames.length > 0) {
const hooksPath: string = await git.getConfigHooksPathAsync();
reportConflict(
terminal,
bypassPolicy,
'Rush cannot install the "common/git-hooks" scripts because your Git configuration ' +
`specifies "core.hooksPath=${hooksPath}". You can remove the setting by running:\n` +
' git config --unset core.hooksPath'
);
}

return;
}

const hookRelativePath: string = Path.convertToSlashes(path.relative(hookDestination, hookSource));
const managedHookNames: Set<string> = new Set();
if (FileSystem.exists(hookDestination)) {
for (const item of FileSystem.readFolderItems(hookDestination)) {
// Never follow symlinks, even when they point to a Rush-generated script.
if (item.isFile() && /^[a-z-]+$/.test(item.name)) {
const content: string = FileSystem.readFile(path.join(hookDestination, item.name));
const legacyContent: string = getHookScript(hookRelativePath, item.name);
if (content === legacyContent || content === addMarker(legacyContent)) {
managedHookNames.add(item.name);
}
}
}
}

// Preflight all collisions before modifying either directory.
const conflicts: string[] = hookFilenames.filter((name) => {
if (managedHookNames.has(name)) {
return false;
}

try {
// Resolve the actual destination, including differently cased names on case-insensitive
// filesystems and dangling symlinks that an existence check would miss.
FileSystem.getLinkStatistics(path.join(hookDestination, name));
return true;
} catch (error) {
if (FileSystem.isNotExistError(error as Error)) {
return false;
}

throw error;
}
});
if (conflicts.length > 0) {
reportConflict(
terminal,
bypassPolicy,
`Rush will not overwrite hooks that it does not own in "${hookDestination}": ${conflicts.join(', ')}.\n` +
'Move the custom logic into common/git-hooks and remove the conflicting installed hooks only after ' +
'reviewing them. For Git LFS hooks, instead remove the same-name files from common/git-hooks ' +
'and let "git lfs install --local" manage them.'
);
return;
}

const lfsHookNames: Set<string> = new Set(hookFilenames.filter((name) => gitLfsHooks.has(name)));
for (const name of managedHookNames) {
if (!hookFilenames.includes(name)) {
if (gitLfsHooks.has(name)) {
// A stale LFS delegate may be the only hook uploading LFS objects. Preserve it until the
// user can replace it with a canonical LFS hook, rather than silently disabling uploads.
lfsHookNames.add(name);
} else {
FileSystem.deleteFile(path.join(hookDestination, name));
}
}
}

if (lfsHookNames.size > 0) {
terminal.writeWarningLine(
`Rush-managed Git LFS hooks (${Array.from(lfsHookNames).join(', ')}) are incompatible with ` +
'"git lfs install --local", including Azure Pipelines LFS checkout on reused workspaces. ' +
'To use that checkout flow, remove these hook names from common/git-hooks, then remove only ' +
'their Rush-generated delegates from the Git hooks directory and run "git lfs install --local" ' +
'before the next checkout. Do not use --force if it would overwrite custom hooks.'
);
}

if (hookFilenames.length === 0) {
return;
}

terminal.writeLine('\n' + Colorize.bold('Found files in the "common/git-hooks" folder.'));
FileSystem.ensureFolder(hookDestination);
for (const filename of hookFilenames) {
const hookFilePath: string = path.join(hookSource, filename);
const originalHookFileContent: string = FileSystem.readFile(hookFilePath);
FileSystem.writeFile(hookFilePath, originalHookFileContent, {
convertLineEndings: NewlineKind.Lf
});
const originalPosixModeBits: PosixModeBits = FileSystem.getPosixModeBits(hookFilePath);
FileSystem.changePosixModeBits(
hookFilePath,
// eslint-disable-next-line no-bitwise
originalPosixModeBits | PosixModeBits.UserRead | PosixModeBits.UserExecute
);

const destinationPath: string = path.join(hookDestination, filename);
if (managedHookNames.has(filename)) {
// Installed delegates are read-only. Remove only the verified Rush-owned file before replacing it.
FileSystem.deleteFile(destinationPath);
}

FileSystem.writeFile(destinationPath, addMarker(getHookScript(hookRelativePath, filename)), {
convertLineEndings: NewlineKind.Lf
});
FileSystem.changePosixModeBits(
destinationPath,
// eslint-disable-next-line no-bitwise
PosixModeBits.UserRead | PosixModeBits.UserExecute
);
}

terminal.writeLine('Successfully installed these Git hook scripts: ' + hookFilenames.join(', ') + '\n');
}
}

function reportConflict(terminal: ITerminal, bypassPolicy: boolean, message: string): void {
if (bypassPolicy) {
terminal.writeWarningLine(
message + '\nSkipping Git hook installation because --bypass-policy was specified.'
);
} else {
terminal.writeErrorLine(
message +
`\nTo temporarily skip Git hook installation, invoke Rush with "${RushConstants.bypassPolicyFlagLongName}".`
);
throw new AlreadyReportedError();
}
}

function addMarker(script: string): string {
return script.replace('#!/usr/bin/env bash\n', '#!/usr/bin/env bash\n' + generatedHookMarker);
}

function getHookScript(hookRelativePath: string, filename: string): string {
// Keep the unmarked template identical to older Rush versions so that only unmodified legacy
// delegates are recognized as Rush-owned. A similar-looking or edited script is not ours to delete.
const gitLfsHookHandling: string = gitLfsHooks.has(filename)
? `
# Inspired by https://github.com/git-lfs/git-lfs/issues/2865#issuecomment-365742940
if command -v git-lfs &> /dev/null; then
git lfs ${filename} "$@"
fi
`
: '';

return `#!/usr/bin/env bash
set -e
SCRIPT_DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SCRIPT_IMPLEMENTATION_PATH="$SCRIPT_DIR/${hookRelativePath}/${filename}"

if [[ -f "$SCRIPT_IMPLEMENTATION_PATH" ]]; then
"$SCRIPT_IMPLEMENTATION_PATH" $@
else
echo "The ${filename} Git hook no longer exists in your version of the repo. Run 'rush install' or 'rush update' to refresh your installed Git hooks." >&2
fi
${gitLfsHookHandling}
`;
}
Loading
Loading