// 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 ignore from 'ignore';
import { Path, FileSystem, Async, AlreadyReportedError } from '@rushstack/node-core-library';
import { getRepoChanges, getRepoRoot, getDetailedRepoStateAsync, hashFilesAsync } from '@rushstack/package-deps-hash';
import { RushProjectConfiguration } from '../api/RushProjectConfiguration';
import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile';
import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile';
import { Git } from './Git';
import { InputsSnapshot } from './incremental/InputsSnapshot';
/**
 * @beta
 */
export class ProjectChangeAnalyzer {
    constructor(rushConfiguration) {
        this._rushConfiguration = rushConfiguration;
        this._git = new Git(this._rushConfiguration);
    }
    /**
     * Gets a list of projects that have changed in the current state of the repo
     * when compared to the specified branch, optionally taking the shrinkwrap and settings in
     * the rush-project.json file into consideration.
     */
    async getChangedProjectsAsync(options) {
        const { _rushConfiguration: rushConfiguration } = this;
        const { targetBranchName, terminal, includeExternalDependencies, enableFiltering, shouldFetch, variant } = options;
        const gitPath = this._git.getGitPathOrThrow();
        const repoRoot = getRepoRoot(rushConfiguration.rushJsonFolder);
        // if the given targetBranchName is a commit, we assume it is the merge base
        const isTargetBranchACommit = await this._git.determineIfRefIsACommitAsync(targetBranchName);
        const mergeCommit = isTargetBranchACommit
            ? targetBranchName
            : await this._git.getMergeBaseAsync(targetBranchName, terminal, shouldFetch);
        const changedFiles = getRepoChanges(repoRoot, mergeCommit, gitPath);
        const lookup = rushConfiguration.getProjectLookupForRoot(repoRoot);
        const changesByProject = this.getChangesByProject(lookup, changedFiles);
        const changedProjects = new Set();
        if (enableFiltering) {
            // Reading rush-project.json may be problematic if, e.g. rush install has not yet occurred and rigs are in use
            await Async.forEachAsync(changesByProject, async ([project, projectChanges]) => {
                const filteredChanges = await this._filterProjectDataAsync(project, projectChanges, repoRoot, terminal);
                if (filteredChanges.size > 0) {
                    changedProjects.add(project);
                }
            }, { concurrency: 10 });
        }
        else {
            for (const [project, projectChanges] of changesByProject) {
                if (projectChanges.size > 0) {
                    changedProjects.add(project);
                }
            }
        }
        // External dependency changes are not allowed to be filtered, so add these after filtering
        if (includeExternalDependencies) {
            // Even though changing the installed version of a nested dependency merits a change file,
            // ignore lockfile changes for `rush change` for the moment
            const subspaces = rushConfiguration.subspacesFeatureEnabled
                ? rushConfiguration.subspaces
                : [rushConfiguration.defaultSubspace];
            const variantToUse = variant !== null && variant !== void 0 ? variant : (await this._rushConfiguration.getCurrentlyInstalledVariantAsync());
            await Async.forEachAsync(subspaces, async (subspace) => {
                const fullShrinkwrapPath = subspace.getCommittedShrinkwrapFilePath(variantToUse);
                const relativeShrinkwrapFilePath = Path.convertToSlashes(path.relative(repoRoot, fullShrinkwrapPath));
                const shrinkwrapStatus = changedFiles.get(relativeShrinkwrapFilePath);
                const subspaceProjects = subspace.getProjects();
                if (shrinkwrapStatus) {
                    if (shrinkwrapStatus.status !== 'M') {
                        if (rushConfiguration.subspacesFeatureEnabled) {
                            terminal.writeLine(`"${subspace.subspaceName}" subspace lockfile was created or deleted. Assuming all projects are affected.`);
                        }
                        else {
                            terminal.writeLine(`Lockfile was created or deleted. Assuming all projects are affected.`);
                        }
                        for (const project of subspaceProjects) {
                            changedProjects.add(project);
                        }
                        return;
                    }
                    if (rushConfiguration.isPnpm) {
                        const subspaceHasNoProjects = subspace.getProjects().length === 0;
                        const currentShrinkwrap = PnpmShrinkwrapFile.loadFromFile(fullShrinkwrapPath, { subspaceHasNoProjects });
                        if (!currentShrinkwrap) {
                            throw new Error(`Unable to obtain current shrinkwrap file.`);
                        }
                        const oldShrinkwrapText = await this._git.getBlobContentAsync({
                            // <ref>:<path> syntax: https://git-scm.com/docs/gitrevisions
                            blobSpec: `${mergeCommit}:${relativeShrinkwrapFilePath}`,
                            repositoryRoot: repoRoot
                        });
                        const oldShrinkWrap = PnpmShrinkwrapFile.loadFromString(oldShrinkwrapText, {
                            subspaceHasNoProjects
                        });
                        for (const project of subspaceProjects) {
                            if (currentShrinkwrap
                                .getProjectShrinkwrap(project)
                                .hasChanges(oldShrinkWrap.getProjectShrinkwrap(project))) {
                                changedProjects.add(project);
                            }
                        }
                    }
                    else {
                        if (rushConfiguration.subspacesFeatureEnabled) {
                            terminal.writeLine(`"${subspace.subspaceName}" subspace lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`);
                        }
                        else {
                            terminal.writeLine(`Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`);
                        }
                        subspace.getProjects().forEach((project) => changedProjects.add(project));
                        return;
                    }
                }
            });
        }
        return changedProjects;
    }
    getChangesByProject(lookup, changedFiles) {
        return lookup.groupByChild(changedFiles);
    }
    /**
     * Gets a snapshot of the input state of the Rush workspace that can be queried for incremental
     * build operations and use by the build cache.
     * @internal
     */
    async _tryGetSnapshotProviderAsync(projectConfigurations, terminal, projectSelection) {
        var _a, _b;
        try {
            const gitPath = this._git.getGitPathOrThrow();
            if (!this._git.isPathUnderGitWorkingTree()) {
                terminal.writeLine(`The Rush monorepo is not in a Git repository. Rush will proceed without incremental build support.`);
                return;
            }
            const rushConfiguration = this._rushConfiguration;
            // Do not use getGitInfo().root; it is the root of the *primary* worktree, not the *current* one.
            const rootDirectory = getRepoRoot(rushConfiguration.rushJsonFolder, gitPath);
            // Load the rush-project.json files for the whole repository
            const additionalGlobs = [];
            const projectMap = new Map();
            for (const project of rushConfiguration.projects) {
                const projectConfig = projectConfigurations.get(project);
                const additionalFilesByOperationName = new Map();
                const projectMetadata = {
                    projectConfig,
                    additionalFilesByOperationName
                };
                projectMap.set(project, projectMetadata);
                if (projectConfig) {
                    const { operationSettingsByOperationName } = projectConfig;
                    for (const [operationName, { dependsOnAdditionalFiles }] of operationSettingsByOperationName) {
                        if (dependsOnAdditionalFiles) {
                            const additionalFilesForOperation = new Set();
                            additionalFilesByOperationName.set(operationName, additionalFilesForOperation);
                            for (const pattern of dependsOnAdditionalFiles) {
                                additionalGlobs.push({
                                    project,
                                    operationName,
                                    additionalFilesForOperation,
                                    pattern
                                });
                            }
                        }
                    }
                }
            }
            // Include project shrinkwrap files as part of the computation
            const additionalRelativePathsToHash = [];
            const globalAdditionalFiles = [];
            if (rushConfiguration.isPnpm) {
                await Async.forEachAsync(rushConfiguration.projects, async (project) => {
                    const projectShrinkwrapFilePath = BaseProjectShrinkwrapFile.getFilePathForProject(project);
                    if (!(await FileSystem.existsAsync(projectShrinkwrapFilePath))) {
                        if (rushConfiguration.subspacesFeatureEnabled) {
                            return;
                        }
                        throw new Error(`A project dependency file (${projectShrinkwrapFilePath}) is missing. You may need to run ` +
                            '"rush install" or "rush update".');
                    }
                    const relativeProjectShrinkwrapFilePath = Path.convertToSlashes(path.relative(rootDirectory, projectShrinkwrapFilePath));
                    additionalRelativePathsToHash.push(relativeProjectShrinkwrapFilePath);
                });
            }
            else {
                // Add the shrinkwrap file to every project's dependencies
                const currentVariant = await this._rushConfiguration.getCurrentlyInstalledVariantAsync();
                const shrinkwrapFile = Path.convertToSlashes(path.relative(rootDirectory, rushConfiguration.defaultSubspace.getCommittedShrinkwrapFilePath(currentVariant)));
                globalAdditionalFiles.push(shrinkwrapFile);
            }
            const lookupByPath = this._rushConfiguration.getProjectLookupForRoot(rootDirectory);
            let filterPath = [];
            if (projectSelection &&
                projectSelection.size > 0 &&
                this._rushConfiguration.experimentsConfiguration.configuration.enableSubpathScan) {
                filterPath = Array.from(projectSelection, ({ projectFolder }) => projectFolder);
            }
            return async function tryGetSnapshotAsync() {
                var _a, _b;
                try {
                    const [{ files: hashes, symlinks, hasUncommittedChanges }, additionalFiles] = await Promise.all([
                        getDetailedRepoStateAsync(rootDirectory, additionalRelativePathsToHash, gitPath, filterPath),
                        getAdditionalFilesFromRushProjectConfigurationAsync(additionalGlobs, lookupByPath, rootDirectory, terminal)
                    ]);
                    if (symlinks.size > 0) {
                        terminal.writeWarningLine(`Warning: Detected ${symlinks.size} Git-tracked symlinks in the repository. ` +
                            `These will be ignored by the change detection engine.`);
                    }
                    for (const file of additionalFiles) {
                        if (hashes.has(file) || symlinks.has(file)) {
                            additionalFiles.delete(file);
                        }
                    }
                    const additionalHashes = new Map(await hashFilesAsync(rootDirectory, additionalFiles, gitPath));
                    return new InputsSnapshot({
                        additionalHashes,
                        globalAdditionalFiles,
                        hashes,
                        hasUncommittedChanges,
                        lookupByPath,
                        projectMap,
                        rootDir: rootDirectory
                    });
                }
                catch (e) {
                    // If getRepoState fails, don't fail the whole build. Treat this case as if we don't know anything about
                    // the state of the files in the repo. This can happen if the environment doesn't have Git.
                    terminal.writeWarningLine(`Error calculating the state of the repo. (inner error: ${(_b = (_a = e.stack) !== null && _a !== void 0 ? _a : e.message) !== null && _b !== void 0 ? _b : e}). Continuing without diffing files.`);
                    return;
                }
            };
        }
        catch (e) {
            // If getRepoState fails, don't fail the whole build. Treat this case as if we don't know anything about
            // the state of the files in the repo. This can happen if the environment doesn't have Git.
            terminal.writeWarningLine(`Error calculating the state of the repo. (inner error: ${(_b = (_a = e.stack) !== null && _a !== void 0 ? _a : e.message) !== null && _b !== void 0 ? _b : e}). Continuing without diffing files.`);
            return;
        }
    }
    /**
     * @internal
     */
    async _filterProjectDataAsync(project, unfilteredProjectData, rootDir, terminal) {
        const ignoreMatcher = await this._getIgnoreMatcherForProjectAsync(project, terminal);
        if (!ignoreMatcher) {
            return unfilteredProjectData;
        }
        const projectKey = path.relative(rootDir, project.projectFolder);
        const projectKeyLength = projectKey.length + 1;
        // At this point, `filePath` is guaranteed to start with `projectKey`, so
        // we can safely slice off the first N characters to get the file path relative to the
        // root of the project.
        const filteredProjectData = new Map();
        for (const [filePath, value] of unfilteredProjectData) {
            const relativePath = filePath.slice(projectKeyLength);
            if (!ignoreMatcher.ignores(relativePath)) {
                // Add the file path to the filtered data if it is not ignored
                filteredProjectData.set(filePath, value);
            }
        }
        return filteredProjectData;
    }
    async _getIgnoreMatcherForProjectAsync(project, terminal) {
        const incrementalBuildIgnoredGlobs = await RushProjectConfiguration.tryLoadIgnoreGlobsForProjectAsync(project, terminal);
        if (incrementalBuildIgnoredGlobs && incrementalBuildIgnoredGlobs.length) {
            const ignoreMatcher = ignore();
            ignoreMatcher.add(incrementalBuildIgnoredGlobs);
            return ignoreMatcher;
        }
    }
}
async function getAdditionalFilesFromRushProjectConfigurationAsync(additionalGlobs, rootRelativeLookupByPath, rootDirectory, terminal) {
    const additionalFilesFromRushProjectConfiguration = new Set();
    if (!additionalGlobs.length) {
        return additionalFilesFromRushProjectConfiguration;
    }
    const { default: glob } = await import('fast-glob');
    await Async.forEachAsync(additionalGlobs, async (item) => {
        const { project, operationName, additionalFilesForOperation, pattern } = item;
        const matches = await glob(pattern, {
            cwd: project.projectFolder,
            onlyFiles: true,
            // We want to keep path's type unchanged,
            // i.e. if the pattern was a  relative path, then matched paths should also be relative paths
            //      if the pattern was an absolute path, then matched paths should also be absolute paths
            //
            // We are doing this because these paths are going to be used to calculate operation state hashes and some users
            // might choose to depend on global files (e.g. `/etc/os-release`) and some might choose to depend on local non-project files
            // (e.g. `../path/to/workspace/file`)
            //
            // In both cases we want that path to the resource to be the same on all machines,
            // regardless of what is the current working directory.
            //
            // That being said, we want to keep `absolute` options here as false:
            absolute: false
        });
        for (const match of matches) {
            // The glob result is relative to the project folder, but we want it to be relative to the repo root
            const rootRelativeFilePath = Path.convertToSlashes(path.relative(rootDirectory, path.resolve(project.projectFolder, match)));
            if (rootRelativeFilePath.startsWith('../')) {
                // The target file is outside of the Git tree, use the original result of the match.
                additionalFilesFromRushProjectConfiguration.add(match);
                additionalFilesForOperation.add(match);
            }
            else {
                // The target file is inside of the Git tree, find out if it is in a Rush project.
                const projectMatch = rootRelativeLookupByPath.findChildPath(rootRelativeFilePath);
                if (projectMatch && projectMatch !== project) {
                    terminal.writeErrorLine(`In project "${project.packageName}" ("${project.projectRelativeFolder}"), ` +
                        `config for operation "${operationName}" specifies a glob "${pattern}" that selects a file "${rootRelativeFilePath}" in a different workspace project ` +
                        `"${projectMatch.packageName}" ("${projectMatch.projectRelativeFolder}"). ` +
                        `This is forbidden. The "dependsOnAdditionalFiles" property of "rush-project.json" may only be used to refer to non-workspace files, non-project files, ` +
                        `or untracked files in the current project. To depend on files in another workspace project, use "devDependencies" in "package.json".`);
                    throw new AlreadyReportedError();
                }
                additionalFilesForOperation.add(rootRelativeFilePath);
                additionalFilesFromRushProjectConfiguration.add(rootRelativeFilePath);
            }
        }
    });
    return additionalFilesFromRushProjectConfiguration;
}
//# sourceMappingURL=ProjectChangeAnalyzer.js.map