Skip to content

Instantly share code, notes, and snippets.

@wbern
Last active August 17, 2021 18:13
Show Gist options
  • Select an option

  • Save wbern/d0ffb116804b2c4bb61da10249500163 to your computer and use it in GitHub Desktop.

Select an option

Save wbern/d0ffb116804b2c4bb61da10249500163 to your computer and use it in GitHub Desktop.

Revisions

  1. wbern revised this gist Jun 1, 2021. 3 changed files with 4 additions and 3 deletions.
    2 changes: 1 addition & 1 deletion lib.test.ts
    Original file line number Diff line number Diff line change
    @@ -164,4 +164,4 @@ const exec = (forceChangedProjects?: string) =>
    });
    });
    });
    });
    });
    2 changes: 1 addition & 1 deletion lib.ts
    Original file line number Diff line number Diff line change
    @@ -151,4 +151,4 @@ export function getCommand(
    (parsedForceChangedProjects.length > 0 ? " " + parsedForceChangedProjects.join(" ") : "");

    return commandToRun;
    }
    }
    3 changes: 2 additions & 1 deletion util.ts
    Original file line number Diff line number Diff line change
    @@ -50,6 +50,7 @@ export function getChangesFromCommit(commitToCompareFrom: string): string[] {
    "--no-pager",
    "diff",
    "--name-only",
    "--staged",
    "--no-renames",
    commitToCompareFrom,
    ])
    @@ -113,4 +114,4 @@ export function parseBuildDirectionsString(buildDirectionsJson: string): string[
    `Could not parse second argument "buildDirections"; needs to be an array like ['--to', '--from']`
    );
    }
    }
    }
  2. wbern revised this gist May 18, 2021. 2 changed files with 3 additions and 3 deletions.
    2 changes: 1 addition & 1 deletion lib.test.ts
    Original file line number Diff line number Diff line change
    @@ -164,4 +164,4 @@ const exec = (forceChangedProjects?: string) =>
    });
    });
    });
    });
    });
    4 changes: 2 additions & 2 deletions lib.ts
    Original file line number Diff line number Diff line change
    @@ -103,7 +103,7 @@ export function getCommand(
    if (!mapFileToProject()) {
    if (
    !isGlobalFileSafeFromSideEffects(
    [".dev-proxyrc.js", "README.MD", "jsconfig.json", ".vscode", "chart"],
    [".dev-proxyrc.js", "README.MD", "jsconfig.json", ".vscode/launch.json", "chart"],
    file
    )
    ) {
    @@ -151,4 +151,4 @@ export function getCommand(
    (parsedForceChangedProjects.length > 0 ? " " + parsedForceChangedProjects.join(" ") : "");

    return commandToRun;
    }
    }
  3. wbern revised this gist May 18, 2021. 1 changed file with 7 additions and 1 deletion.
    8 changes: 7 additions & 1 deletion util.ts
    Original file line number Diff line number Diff line change
    @@ -15,6 +15,12 @@ export function getRushConfig(): RushConfiguration {

    rushJson.rushJsonFolder = repoRoot;

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    rushJson.projects = rushJson.projects.map((project: any) => ({
    ...project,
    projectFolder: path.resolve(repoRoot, project.projectFolder),
    }));

    return rushJson as RushConfiguration;
    }

    @@ -107,4 +113,4 @@ export function parseBuildDirectionsString(buildDirectionsJson: string): string[
    `Could not parse second argument "buildDirections"; needs to be an array like ['--to', '--from']`
    );
    }
    }
    }
  4. wbern revised this gist May 18, 2021. 1 changed file with 10 additions and 1 deletion.
    11 changes: 10 additions & 1 deletion util.ts
    Original file line number Diff line number Diff line change
    @@ -1,12 +1,21 @@
    import { spawnSync } from "child_process";
    import process from "process";
    import path from "path";
    import fs from "fs";

    import { RushConfiguration } from "@microsoft/rush-lib";

    const debug = true;

    export function getRushConfig(): RushConfiguration {
    return RushConfiguration.loadFromDefaultLocation();
    const repoRoot = path.join(__dirname, "../../../");
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    let rushJson: any;
    eval(`rushJson = ${fs.readFileSync(path.join(repoRoot, "rush.json")).toString()}`);

    rushJson.rushJsonFolder = repoRoot;

    return rushJson as RushConfiguration;
    }

    export function printDebug(msg: string): void {
  5. wbern created this gist May 18, 2021.
    21 changes: 21 additions & 0 deletions bin.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,21 @@
    import { getCommand } from "./lib";
    import { printCommand } from "./util";
    import process from "process";

    function main() {
    const [
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    __,
    command = "sleep",
    buildDirections = "['--to', '--from']",
    forceChangedProjects = "",
    ] = process.argv;

    const commandToExecute = getCommand(command, buildDirections, forceChangedProjects);

    printCommand(commandToExecute);
    }

    main();
    3 changes: 3 additions & 0 deletions interfaces.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,3 @@
    export interface FilesInProjectsOrGlobal {
    [key: string]: string[];
    }
    167 changes: 167 additions & 0 deletions lib.test.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,167 @@
    import * as util from "./util";
    import * as lib from "./lib";

    const actualUtil = jest.requireActual("./util");

    const mockedUtil = util as jest.Mocked<typeof util>;

    const mockCommits = [
    // latest commit
    "abcdef",
    "hijkmn",
    "opqrst",
    ];

    jest.mock("./util");
    const exec = (forceChangedProjects?: string) =>
    lib.getCommand("sleep", "['--to', '--from']", forceChangedProjects);

    [true, false].forEach((CI) => {
    const getMockingMethodForChangedFiles = () =>
    CI ? mockedUtil.getChangesFromCommit : mockedUtil.getStagedChanges;

    const commitToCompareFrom = CI
    ? mockCommits[Math.floor(Math.random() * mockCommits.length)]
    : mockCommits[0];

    describe(`Given we are running the script on ${
    CI ? "the jenkins pipeline" : "a local machine"
    }`, () => {
    beforeEach(() => {
    jest.clearAllMocks();

    mockedUtil.parseBuildDirectionsString = actualUtil.parseBuildDirectionsString;
    mockedUtil.getCurrentCommit.mockImplementation(() => mockCommits[0]);

    // @ts-expect-error we dont want to stub the complete rush config
    mockedUtil.getRushConfig.mockImplementation(() => {
    return {
    rushJsonFolder: "/home/user/repo",
    projects: [
    { packageName: "project-a", projectFolder: "/home/user/repo/apps/project-a" },
    { packageName: "project-b", projectFolder: "/home/user/repo/apps/project-b" },
    { packageName: "project-c", projectFolder: "/home/user/repo/apps/project-c" },
    ],
    };
    });

    mockedUtil.isCI.mockReturnValue(CI);

    if (CI) {
    mockedUtil.getPreviousSuccessfulCommit.mockReturnValueOnce(commitToCompareFrom);
    }
    });

    describe("and we have some changes inside a project", () => {
    it("should output the input command, plus found project.", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => [
    "apps/project-a/src/index.js",
    ]);
    const expectedResult = "sleep --to project-a --from project-a";

    expect(exec()).toBe(expectedResult);

    getMockingMethodForChangedFiles().mockImplementationOnce(() => [
    "apps/project-a/src/index.js",
    "apps/project-a/src/util.js",
    "apps/project-a/package.json",
    ]);

    expect(exec()).toBe(expectedResult);
    });

    describe("but the file is specified to be ignored", () => {
    it("should output a voiding echo command", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => [
    "apps/project-a/src/index.js",
    ]);
    mockedUtil.isProjectFileToBeIgnoredForChangeDetection.mockImplementationOnce(() => true);

    expect(exec()).toBe(
    `echo "skipping sleep because no projects have changed from commit hash ${commitToCompareFrom}"`
    );
    });
    });
    });

    describe("and we have some changes inside multiple projects", () => {
    it("should output the input command, plus found project(s).", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => [
    "apps/project-a/src/index.js",
    "apps/project-b/src/index.js",
    ]);

    const expectedResult =
    "sleep --to project-a --from project-a --to project-b --from project-b";

    expect(exec()).toBe(expectedResult);

    getMockingMethodForChangedFiles().mockImplementationOnce(() => [
    "apps/project-a/src/index.js",
    "apps/project-b/package.json",
    "apps/project-b/src/index.js",
    "apps/project-b/package.json",
    ]);

    expect(exec()).toBe(expectedResult);
    });
    });

    it("should output the input command, plus the forced projects", async () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => [
    "apps/project-a/src/index.js",
    "apps/project-b/src/index.js",
    ]);

    const expectedResult =
    "sleep --to project-a --from project-a --to project-b --from project-b";

    expect(exec("project-a")).toBe(expectedResult);
    });

    describe("and we have modified a file outside the projects", () => {
    it("should build everything", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => ["package.json"]);

    expect(exec()).toBe("sleep");
    });

    describe("that global file is specified to be safe from side-effects to projects", () => {
    it("should output a voiding echo command", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => ["package.json"]);
    mockedUtil.isGlobalFileSafeFromSideEffects.mockImplementationOnce(() => true);

    expect(exec()).toBe(
    `echo "skipping sleep because no projects have changed from commit hash ${commitToCompareFrom}"`
    );
    });
    });

    describe("but we have specified some project to be forced to be included", () => {
    it("should output the input command, plus the forced projects", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => ["package.json"]);

    expect(exec("project-a")).toBe("sleep");
    });
    });
    });

    describe("and we have not made any changes", () => {
    it("should output a voiding echo command", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => []);

    expect(exec()).toBe(
    `echo "skipping sleep because no projects have changed from commit hash ${commitToCompareFrom}"`
    );
    });

    describe("but we have specified some project to be forced to be included", () => {
    it("should output the input command, plus the forced projects", () => {
    getMockingMethodForChangedFiles().mockImplementationOnce(() => []);

    expect(exec("project-a")).toBe("sleep --to project-a --from project-a");
    });
    });
    });
    });
    });
    154 changes: 154 additions & 0 deletions lib.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,154 @@
    import { RushConfigurationProject } from "@microsoft/rush-lib";
    import path from "path";
    import {
    printDebug,
    isCI,
    parseBuildDirectionsString,
    getRushConfig,
    getChangesFromCommit,
    getBranchNameInPipeline,
    getCurrentCommit,
    getStagedChanges,
    getMergeBaseCommit,
    getCustomCompareGitCommit,
    getPreviousSuccessfulCommit,
    isProjectFileToBeIgnoredForChangeDetection,
    isGlobalFileSafeFromSideEffects,
    } from "./util";

    import { FilesInProjectsOrGlobal } from "./interfaces";

    export function getCommand(
    command: string,
    buildDirectionsJson = "['--to', '--from']",
    forceChangedProjects = ""
    ): string {
    const rushJson = getRushConfig();

    const buildDirections: string[] = parseBuildDirectionsString(buildDirectionsJson);

    let commitToCompareFrom;
    let files;

    if (isCI()) {
    const previousSuccessfulBuildCommit = getPreviousSuccessfulCommit();

    if (getBranchNameInPipeline() === "master" && previousSuccessfulBuildCommit === null) {
    // master is brand new. This is rare/untested, but that likely means we'll want execute for every project.
    return command;
    }

    commitToCompareFrom =
    getCustomCompareGitCommit() || previousSuccessfulBuildCommit || getMergeBaseCommit();

    if (commitToCompareFrom === null) {
    // might be first commit ever made? Execute everything
    return command;
    }

    printDebug(
    "[CI] comparing with commit hash " +
    (previousSuccessfulBuildCommit
    ? "(from a previous successful build) "
    : "(from last ancestor commit to master) ") +
    commitToCompareFrom
    );

    files = getChangesFromCommit(commitToCompareFrom);
    } else {
    // executing on local machine
    commitToCompareFrom = getCurrentCommit();

    if (commitToCompareFrom === null) {
    // might be first commit ever made? Execute everything
    return command;
    }

    printDebug("[Local] comparing local staged files with latest commit " + commitToCompareFrom);

    files = getStagedChanges(commitToCompareFrom);
    }

    const categorizedFiles: FilesInProjectsOrGlobal = {
    "[global]": [],
    "[global]-ignored": [],
    };

    const changedProjects = files.reduce((projects: Record<string, string[]>, file) => {
    const resolvedFile = path.join(rushJson.rushJsonFolder, file);

    const mapFileToProject = () =>
    rushJson.projects.some((project: RushConfigurationProject) => {
    if (resolvedFile.startsWith(project.projectFolder)) {
    categorizedFiles[project.packageName] = categorizedFiles[project.packageName] || [];

    if (isProjectFileToBeIgnoredForChangeDetection([], file)) {
    categorizedFiles[`${project.packageName}-ignored`] =
    categorizedFiles[`${project.packageName}-ignored`] || [];
    categorizedFiles[`${project.packageName}-ignored`].push(file);

    return true;
    } else {
    projects[project.packageName] = projects[project.packageName] || [];

    categorizedFiles[project.packageName].push(file);
    projects[project.packageName].push(file);

    return true;
    }
    }
    return false;
    });

    if (!mapFileToProject()) {
    if (
    !isGlobalFileSafeFromSideEffects(
    [".dev-proxyrc.js", "README.MD", "jsconfig.json", ".vscode", "chart"],
    file
    )
    ) {
    categorizedFiles["[global]"].push(file);
    } else {
    categorizedFiles["[global]-ignored"].push(file);
    }
    }

    return projects;
    }, {});

    printDebug(
    "changes:\n" +
    Object.keys(categorizedFiles)
    .filter((key) => categorizedFiles[key].length)
    .map((key) => [`------------------\nScope: ${key}\n`, ...categorizedFiles[key]])
    .flatMap((item) => item)
    .join("\n") +
    "\n\n"
    );

    if (categorizedFiles["[global]"].length > 0) {
    return command;
    }

    if (Object.keys(changedProjects).length === 0 && forceChangedProjects === "") {
    return `echo "skipping ${command} because no projects have changed from commit hash ${commitToCompareFrom}"`;
    }

    const parsedForceChangedProjects = forceChangedProjects
    .split(" ")
    .filter(
    (projectName) => projectName !== "" && !Object.keys(changedProjects).includes(projectName)
    )
    .map((projectName) => buildDirections.flatMap((d) => [d, projectName]).join(" "));

    const commandToRun =
    [
    command,
    ...Object.keys(changedProjects).flatMap((projectName) => {
    return buildDirections.flatMap((d) => [d, projectName]);
    }),
    ].join(" ") +
    (parsedForceChangedProjects.length > 0 ? " " + parsedForceChangedProjects.join(" ") : "");

    return commandToRun;
    }
    101 changes: 101 additions & 0 deletions util.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,101 @@
    import { spawnSync } from "child_process";
    import process from "process";

    import { RushConfiguration } from "@microsoft/rush-lib";

    const debug = true;

    export function getRushConfig(): RushConfiguration {
    return RushConfiguration.loadFromDefaultLocation();
    }

    export function printDebug(msg: string): void {
    if (debug) {
    process.stderr.write(msg);
    }
    }

    export function isProjectFileToBeIgnoredForChangeDetection(
    ignoreArray: string[],
    file: string
    ): boolean {
    return ignoreArray.includes(file);
    }

    export function isGlobalFileSafeFromSideEffects(ignoreArray: string[], file: string): boolean {
    return ignoreArray.includes(file);
    }

    export function getMergeBaseCommit(): string {
    return spawnSync("git", ["merge-base", "HEAD", "origin/master"]).stdout.toString().trim();
    }

    export function getChangesFromCommit(commitToCompareFrom: string): string[] {
    return spawnSync("git", [
    "--no-pager",
    "diff",
    "--name-only",
    "--no-renames",
    commitToCompareFrom,
    ])
    .stdout.toString()
    .trim()
    .split("\n")
    .filter((item) => item !== "");
    }

    export function printCommand(command: string): void {
    // output will be consumed as a command in Jenkinsfile
    // eslint-disable-next-line
    console.log(command);
    }

    export function getCurrentCommit(): string | null {
    try {
    return spawnSync("git", ["rev-parse", "HEAD"]).stdout.toString().trim();
    } catch (e) {
    return null;
    }
    }

    export function getStagedChanges(commitToCompareFrom: string): string[] {
    return spawnSync("git", ["--no-pager", "diff", "--name-only", "--staged", commitToCompareFrom])
    .stdout.toString()
    .trim()
    .split("\n")
    .filter((item) => item !== "");
    }

    export function getCustomCompareGitCommit(): string | null {
    return (process.env && process.env.CUSTOM_COMPARE_GIT_COMMIT) || null;
    }

    export function getPreviousSuccessfulCommit(): string | null {
    if (
    process.env &&
    typeof process.env.GIT_PREVIOUS_NON_FAILED_COMMIT === "string" &&
    /[0-9a-f]{7,40}/.test(process.env.GIT_PREVIOUS_NON_FAILED_COMMIT || "")
    ) {
    return process.env.GIT_PREVIOUS_NON_FAILED_COMMIT;
    }

    return null;
    }

    export function getBranchNameInPipeline(): string | null {
    return process.env?.BRANCH_NAME || null;
    }

    export function isCI(): boolean {
    return !!getBranchNameInPipeline();
    }

    export function parseBuildDirectionsString(buildDirectionsJson: string): string[] {
    try {
    return JSON.parse(buildDirectionsJson.replace(/['`]/g, '"'));
    } catch (e) {
    throw new Error(
    `Could not parse second argument "buildDirections"; needs to be an array like ['--to', '--from']`
    );
    }
    }