// 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 * as url from 'node:url';
import gitInfo from 'git-repo-info';
import { trueCasePathSync } from 'true-case-path';
import { Executable, AlreadyReportedError, Path, Async } from '@rushstack/node-core-library';
import { Colorize } from '@rushstack/terminal';
import { ensureGitMinimumVersion } from '@rushstack/package-deps-hash';
import { Utilities } from '../utilities/Utilities';
import * as GitEmailPolicy from './policy/GitEmailPolicy';
import { EnvironmentConfiguration } from '../api/EnvironmentConfiguration';
import { parseGitStatus } from './GitStatusParser';
import { RushConstants } from './RushConstants';
export const DEFAULT_GIT_TAG_SEPARATOR = '_';
export class Git {
    constructor(rushConfiguration) {
        this._checkedGitPath = false;
        this._checkedGitInfo = false;
        this._gitEmailResult = undefined;
        this._gitHooksPath = undefined;
        this._rushConfiguration = rushConfiguration;
    }
    /**
     * Returns the path to the Git binary if found. Otherwise, return undefined.
     */
    get gitPath() {
        if (!this._checkedGitPath) {
            this._gitPath = EnvironmentConfiguration.gitBinaryPath || Executable.tryResolve('git');
            this._checkedGitPath = true;
        }
        return this._gitPath;
    }
    getGitPathOrThrow() {
        const gitPath = this.gitPath;
        if (!gitPath) {
            throw new Error('Git is not present');
        }
        else {
            return gitPath;
        }
    }
    /**
     * Returns true if the Git binary can be found.
     */
    isGitPresent() {
        return !!this.gitPath;
    }
    /**
     * Returns true if the Git binary was found and the current path is under a Git working tree.
     * @param repoInfo - If provided, do the check based on this Git repo info. If not provided,
     * the result of `this.getGitInfo()` is used.
     */
    isPathUnderGitWorkingTree(repoInfo) {
        if (this.isGitPresent()) {
            // Do we even have a Git binary?
            if (!repoInfo) {
                repoInfo = this.getGitInfo();
            }
            return !!(repoInfo && repoInfo.sha);
        }
        else {
            return false;
        }
    }
    /**
     * If a Git email address is configured and is nonempty, this returns it.
     * Otherwise, configuration instructions are printed to the console,
     * and AlreadyReportedError is thrown.
     */
    async getGitEmailAsync() {
        // Determine the user's account
        // Ex: "bob@example.com"
        const { error, result } = await this._tryGetGitEmailAsync();
        if (error) {
            // eslint-disable-next-line no-console
            console.log([
                `Error: ${error.message}`,
                'Unable to determine your Git configuration using this command:',
                '',
                '    git config user.email',
                ''
            ].join('\n'));
            throw new AlreadyReportedError();
        }
        return this.validateGitEmail(result);
    }
    /**
     * If the Git email address is configured and non-empty, this returns it. Otherwise
     * it prints an error message and throws.
     */
    validateGitEmail(userEmail) {
        if (userEmail === undefined || userEmail.length === 0) {
            // eslint-disable-next-line no-console
            console.log([
                'This operation requires that a Git email be specified.',
                '',
                `If you didn't configure your email yet, try something like this:`,
                '',
                ...GitEmailPolicy.getEmailExampleLines(this._rushConfiguration),
                ''
            ].join('\n'));
            throw new AlreadyReportedError();
        }
        return userEmail;
    }
    /**
     * Get the folder where Git hooks should go for the current working tree.
     * Returns undefined if the current path is not under a Git working tree.
     */
    getHooksFolder() {
        const repoInfo = this.getGitInfo();
        if (repoInfo && repoInfo.worktreeGitDir) {
            return path.join(repoInfo.worktreeGitDir, 'hooks');
        }
        return undefined;
    }
    async getIsHooksPathDefaultAsync() {
        const repoInfo = this.getGitInfo();
        if (!(repoInfo === null || repoInfo === void 0 ? void 0 : repoInfo.commonGitDir)) {
            // This should have never been called in a non-Git environment
            return true;
        }
        let commonGitDir = repoInfo.commonGitDir;
        try {
            commonGitDir = trueCasePathSync(commonGitDir);
        }
        catch (error) {
            /* ignore errors from true-case-path */
        }
        const defaultHooksPath = path.resolve(commonGitDir, 'hooks');
        const hooksResult = await this._tryGetGitHooksPathAsync();
        if (hooksResult.error) {
            // eslint-disable-next-line no-console
            console.log([
                `Error: ${hooksResult.error.message}`,
                'Unable to determine your Git configuration using this command:',
                '',
                '    git rev-parse --git-path hooks',
                '',
                'Assuming hooks can still be installed in the default location'
            ].join('\n'));
            return true;
        }
        if (hooksResult.result) {
            const absoluteHooksPath = path.resolve(this._rushConfiguration.rushJsonFolder, hooksResult.result);
            return absoluteHooksPath === defaultHooksPath;
        }
        // No error, but also empty result? Not sure it's possible.
        return true;
    }
    async getConfigHooksPathAsync() {
        let configHooksPath = '';
        const gitPath = this.getGitPathOrThrow();
        try {
            configHooksPath = (await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['config', 'core.hooksPath'])).trim();
        }
        catch (e) {
            // git config returns error code 1 if core.hooksPath is not set.
        }
        return configHooksPath;
    }
    /**
     * Get information about the current Git working tree.
     * Returns undefined if rush.json is not under a Git working tree.
     */
    getGitInfo() {
        if (!this._checkedGitInfo) {
            let repoInfo;
            try {
                // gitInfo() shouldn't usually throw, but wrapping in a try/catch just in case
                repoInfo = gitInfo(this._rushConfiguration.rushJsonFolder);
            }
            catch (ex) {
                // if there's an error, assume we're not in a Git working tree
            }
            if (repoInfo && this.isPathUnderGitWorkingTree(repoInfo)) {
                this._gitInfo = repoInfo;
            }
            this._checkedGitInfo = true;
        }
        return this._gitInfo;
    }
    async getMergeBaseAsync(targetBranch, terminal, shouldFetch = false) {
        if (shouldFetch) {
            this._fetchRemoteBranch(targetBranch, terminal);
        }
        const gitPath = this.getGitPathOrThrow();
        try {
            const output = await this._executeGitCommandAndCaptureOutputAsync(gitPath, [
                '--no-optional-locks',
                'merge-base',
                '--',
                'HEAD',
                targetBranch
            ]);
            const result = output.trim();
            return result;
        }
        catch (e) {
            terminal.writeErrorLine(`Unable to determine merge base for branch "${targetBranch}". ` +
                'This can occur if the current clone is a shallow clone. If this clone is running in a CI ' +
                'pipeline, check your pipeline settings to ensure that the clone depth includes ' +
                'the expected merge base. If this clone is running locally, consider running "git fetch --deepen=<depth>".');
            throw new AlreadyReportedError();
        }
    }
    async getBlobContentAsync({ blobSpec, repositoryRoot }) {
        const gitPath = this.getGitPathOrThrow();
        const output = await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['cat-file', 'blob', blobSpec, '--'], repositoryRoot);
        return output;
    }
    /**
     * @param pathPrefix - An optional path prefix "git diff"s should be filtered by.
     * @returns
     * An array of paths of repo-root-relative paths of files that are different from
     * those in the provided {@param targetBranch}. If a {@param pathPrefix} is provided,
     * this function only returns results under the that path.
     */
    async getChangedFilesAsync(targetBranch, terminal, skipFetch = false, pathPrefix) {
        if (!skipFetch) {
            this._fetchRemoteBranch(targetBranch, terminal);
        }
        const gitPath = this.getGitPathOrThrow();
        const output = await this._executeGitCommandAndCaptureOutputAsync(gitPath, [
            'diff',
            `${targetBranch}...`,
            '--name-only',
            '--no-renames',
            '--diff-filter=A'
        ]);
        return output
            .split('\n')
            .map((line) => {
            if (line) {
                const trimmedLine = line.trim();
                if (!pathPrefix || Path.isUnderOrEqual(trimmedLine, pathPrefix)) {
                    return trimmedLine;
                }
            }
            else {
                return undefined;
            }
        })
            .filter((line) => {
            return line && line.length > 0;
        });
    }
    /**
     * Gets the remote default branch that maps to the provided repository url.
     * This method is used by 'Rush change' to find the default remote branch to compare against.
     * If repository url is not provided or if there is no match, returns the default remote's
     * default branch 'origin/main'.
     * If there are more than one matches, returns the first remote's default branch.
     *
     * @param rushConfiguration - rush configuration
     */
    async getRemoteDefaultBranchAsync() {
        const repositoryUrls = this._rushConfiguration.repositoryUrls;
        if (repositoryUrls.length > 0) {
            const gitPath = this.getGitPathOrThrow();
            const output = (await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['remote'])).trim();
            const normalizedRepositoryUrls = new Set();
            for (const repositoryUrl of repositoryUrls) {
                // Apply toUpperCase() for a case-insensitive comparison
                normalizedRepositoryUrls.add(Git.normalizeGitUrlForComparison(repositoryUrl).toUpperCase());
            }
            const matchingRemotes = [];
            await Async.forEachAsync(output.split('\n'), async (remoteName) => {
                if (remoteName) {
                    const remoteUrl = (await this._executeGitCommandAndCaptureOutputAsync(gitPath, [
                        'remote',
                        'get-url',
                        '--',
                        remoteName
                    ])).trim();
                    if (remoteUrl) {
                        // Also apply toUpperCase() for a case-insensitive comparison
                        const normalizedRemoteUrl = Git.normalizeGitUrlForComparison(remoteUrl).toUpperCase();
                        if (normalizedRepositoryUrls.has(normalizedRemoteUrl)) {
                            matchingRemotes.push(remoteName);
                        }
                    }
                }
            }, { concurrency: 10 });
            if (matchingRemotes.length > 0) {
                if (matchingRemotes.length > 1) {
                    // eslint-disable-next-line no-console
                    console.log(`More than one git remote matches the repository URL. Using the first remote (${matchingRemotes[0]}).`);
                }
                return `${matchingRemotes[0]}/${this._rushConfiguration.repositoryDefaultBranch}`;
            }
            else {
                const errorMessage = repositoryUrls.length > 1
                    ? `Unable to find a git remote matching one of the repository URLs (${repositoryUrls.join(', ')}). `
                    : `Unable to find a git remote matching the repository URL (${repositoryUrls[0]}). `;
                // eslint-disable-next-line no-console
                console.log(Colorize.yellow(errorMessage + 'Detected changes are likely to be incorrect.'));
                return this._rushConfiguration.repositoryDefaultFullyQualifiedRemoteBranch;
            }
        }
        else {
            // eslint-disable-next-line no-console
            console.log(Colorize.yellow(`A git remote URL has not been specified in ${RushConstants.rushJsonFilename}. Setting the baseline remote URL is recommended.`));
            return this._rushConfiguration.repositoryDefaultFullyQualifiedRemoteBranch;
        }
    }
    async hasUncommittedChangesAsync() {
        const gitStatusEntries = await this.getGitStatusAsync();
        for (const _ of gitStatusEntries) {
            // If there are any changes, return true. We only need to evaluate the first iterator entry
            return true;
        }
        return false;
    }
    async hasUnstagedChangesAsync() {
        const gitStatusEntries = await this.getGitStatusAsync();
        for (const gitStatusEntry of gitStatusEntries) {
            if (gitStatusEntry.kind === 'untracked' ||
                gitStatusEntry.unstagedChangeType !== undefined) {
                return true;
            }
        }
        return false;
    }
    /**
     * The list of files changed but not committed
     */
    async getUncommittedChangesAsync() {
        const result = [];
        const gitStatusEntries = await this.getGitStatusAsync();
        for (const gitStatusEntry of gitStatusEntries) {
            result.push(gitStatusEntry.path);
        }
        return result;
    }
    getTagSeparator() {
        return this._rushConfiguration.gitTagSeparator || DEFAULT_GIT_TAG_SEPARATOR;
    }
    async getGitStatusAsync() {
        const gitPath = this.getGitPathOrThrow();
        // See Git.test.ts for example output
        const output = await this._executeGitCommandAndCaptureOutputAsync(gitPath, [
            'status',
            '--porcelain=2',
            '--null',
            '--ignored=no'
        ]);
        return parseGitStatus(output);
    }
    /**
     * Git remotes can use different URL syntaxes; this converts them all to a normalized HTTPS
     * representation for matching purposes.  IF THE INPUT IS NOT ALREADY HTTPS, THE OUTPUT IS
     * NOT NECESSARILY A VALID GIT URL.
     *
     * @example
     * `git@github.com:ExampleOrg/ExampleProject.git` --> `https://github.com/ExampleOrg/ExampleProject`
     */
    static normalizeGitUrlForComparison(gitUrl) {
        // Git URL formats are documented here: https://www.git-scm.com/docs/git-clone#_git_urls
        let result = gitUrl.trim();
        // [user@]host.xz:path/to/repo.git/
        // "This syntax is only recognized if there are no slashes before the first colon. This helps
        // differentiate a local path that contains a colon."
        //
        // Match patterns like this:
        //   user@host.ext:path/to/repo
        //   host.ext:path/to/repo
        //   localhost:/~user/path/to/repo
        //
        // But not:
        //   http://blah
        //   c:/windows/path.txt
        //
        const scpLikeSyntaxRegExp = /^(?:[^@:\/]+\@)?([^:\/]{2,})\:((?!\/\/).+)$/;
        // Example: "user@host.ext:path/to/repo"
        const scpLikeSyntaxMatch = scpLikeSyntaxRegExp.exec(gitUrl);
        if (scpLikeSyntaxMatch) {
            // Example: "host.ext"
            const host = scpLikeSyntaxMatch[1];
            // Example: "path/to/repo"
            const urlPath = scpLikeSyntaxMatch[2];
            if (urlPath.startsWith('/')) {
                result = `https://${host}${urlPath}`;
            }
            else {
                result = `https://${host}/${urlPath}`;
            }
        }
        const parsedUrl = url.parse(result);
        // Only convert recognized schemes
        switch (parsedUrl.protocol) {
            case 'http:':
            case 'https:':
            case 'ssh:':
            case 'ftp:':
            case 'ftps:':
            case 'git:':
            case 'git+http:':
            case 'git+https:':
            case 'git+ssh:':
            case 'git+ftp:':
            case 'git+ftps:':
                // Assemble the parts we want:
                result = `https://${parsedUrl.host}${parsedUrl.pathname}`;
                break;
        }
        // Trim ".git" or ".git/" from the end
        result = result.replace(/.git\/?$/, '');
        return result;
    }
    /**
     * This will throw errors only if we cannot find Git commandline.
     * If git email didn't configure, this will return undefined; otherwise,
     * returns user.email config
     */
    async tryGetGitEmailAsync() {
        const { result } = await this._tryGetGitEmailAsync();
        return result;
    }
    async stageAndCommitGitChangesAsync(pattern, message, terminal) {
        try {
            const gitPath = this.getGitPathOrThrow();
            await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['add', '--', ...pattern], this._rushConfiguration.changesFolder);
            await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['commit', '-m', message, '--', ...pattern], this._rushConfiguration.changesFolder);
        }
        catch (error) {
            terminal.writeErrorLine(`ERROR: Cannot stage and commit git changes ${error.message}`);
        }
    }
    /**
     * Returns an object containing either the result of the `git config user.email`
     * command or an error.
     */
    async _tryGetGitEmailAsync() {
        if (this._gitEmailResult === undefined) {
            const gitPath = this.getGitPathOrThrow();
            try {
                this._gitEmailResult = {
                    result: (await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['config', 'user.email'])).trim()
                };
            }
            catch (e) {
                this._gitEmailResult = {
                    error: e
                };
            }
        }
        return this._gitEmailResult;
    }
    async _tryGetGitHooksPathAsync() {
        if (this._gitHooksPath === undefined) {
            const gitPath = this.getGitPathOrThrow();
            try {
                this._gitHooksPath = {
                    result: (await this._executeGitCommandAndCaptureOutputAsync(gitPath, ['rev-parse', '--git-path', 'hooks'])).trim()
                };
            }
            catch (e) {
                this._gitHooksPath = {
                    error: e
                };
            }
        }
        return this._gitHooksPath;
    }
    _tryFetchRemoteBranch(remoteBranchName) {
        const firstSlashIndex = remoteBranchName.indexOf('/');
        if (firstSlashIndex === -1) {
            throw new Error(`Unexpected git remote branch format: ${remoteBranchName}. ` +
                'Expected branch to be in the <remote>/<branch name> format.');
        }
        const remoteName = remoteBranchName.substr(0, firstSlashIndex);
        const branchName = remoteBranchName.substr(firstSlashIndex + 1);
        const gitPath = this.getGitPathOrThrow();
        const spawnResult = Executable.spawnSync(gitPath, ['fetch', '--', remoteName, branchName], {
            stdio: 'ignore'
        });
        return spawnResult.status === 0;
    }
    _fetchRemoteBranch(remoteBranchName, terminal) {
        // eslint-disable-next-line no-console
        console.log(`Checking for updates to ${remoteBranchName}...`);
        const fetchResult = this._tryFetchRemoteBranch(remoteBranchName);
        if (!fetchResult) {
            terminal.writeWarningLine(`Error fetching git remote branch ${remoteBranchName}. Detected changed files may be incorrect.`);
        }
    }
    /**
     * @internal
     */
    async _executeGitCommandAndCaptureOutputAsync(gitPath, args, workingDirectory = this._rushConfiguration.rushJsonFolder) {
        try {
            return await Utilities.executeCommandAndCaptureOutputAsync({
                command: gitPath,
                args,
                workingDirectory
            });
        }
        catch (e) {
            ensureGitMinimumVersion(gitPath);
            throw e;
        }
    }
    /**
     *
     * @param ref Given a ref which can be branch name, commit hash, tag name, etc, check if it is a commit hash
     */
    async determineIfRefIsACommitAsync(ref) {
        const gitPath = this.getGitPathOrThrow();
        try {
            const output = await this._executeGitCommandAndCaptureOutputAsync(gitPath, [
                'rev-parse',
                '--verify',
                ref
            ]);
            const result = output.trim();
            return result === ref;
        }
        catch (e) {
            // assume not a commit
            return false;
        }
    }
}
//# sourceMappingURL=Git.js.map