diff --git a/README.md b/README.md index 1c22d17..eb7569e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ Only lint the changes you've staged for an upcoming commit. } ``` +#### `"plugin:diff/committed"` + +Only lint the changes you've committed, for running in a pre-push hook. You should set `ESLINT_PLUGIN_DIFF_COMMIT` in your pre-push hook for this to be useful. + +```json +{ + "extends": ["plugin:diff/committed"] +} +``` + ## CI Setup To lint all the changes of a pull-request, you only have to set diff --git a/src/__snapshots__/index.test.ts.snap b/src/__snapshots__/index.test.ts.snap index 600bce2..85b8968 100644 --- a/src/__snapshots__/index.test.ts.snap +++ b/src/__snapshots__/index.test.ts.snap @@ -15,6 +15,19 @@ exports[`plugin should match expected export 1`] = ` "diff", ], }, + "committed": { + "overrides": [ + { + "files": [ + "*", + ], + "processor": "diff/committed", + }, + ], + "plugins": [ + "diff", + ], + }, "diff": { "overrides": [ { @@ -51,6 +64,11 @@ exports[`plugin should match expected export 2`] = ` "preprocess": [Function], "supportsAutofix": true, }, + "committed": { + "postprocess": [Function], + "preprocess": [Function], + "supportsAutofix": false, + }, "diff": { "postprocess": [Function], "preprocess": [Function], diff --git a/src/git.test.ts b/src/git.test.ts index feaf9f3..417d2a2 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -48,14 +48,14 @@ describe("getRangesForDiff", () => { describe("getDiffForFile", () => { it("should get the staged diff of a file", () => { - mockedChildProcess.execFileSync.mockReturnValueOnce(Buffer.from(hunks)); + mockedChildProcess.execFileSync.mockReturnValueOnce(hunks); process.env.ESLINT_PLUGIN_DIFF_COMMIT = "1234567"; - const diffFromFile = getDiffForFile("./mockfile.js", true); + const diffFromFile = getDiffForFile("./mockfile.js", "staged"); const expectedCommand = "git"; const expectedArgs = - "diff --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --staged --unified=0 1234567"; + "diff-index --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --cached --unified=0 1234567"; const lastCall = mockedChildProcess.execFileSync.mock.calls.at(-1); const [command, argsIncludingFile = []] = lastCall ?? [""]; @@ -68,14 +68,14 @@ describe("getDiffForFile", () => { }); it("should work when using staged = false", () => { - mockedChildProcess.execFileSync.mockReturnValueOnce(Buffer.from(hunks)); + mockedChildProcess.execFileSync.mockReturnValueOnce(hunks); process.env.ESLINT_PLUGIN_DIFF_COMMIT = "1234567"; - const diffFromFile = getDiffForFile("./mockfile.js", false); + const diffFromFile = getDiffForFile("./mockfile.js", "working"); const expectedCommand = "git"; const expectedArgs = - "diff --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --unified=0 1234567"; + "diff-index --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --unified=0 1234567"; const lastCall = mockedChildProcess.execFileSync.mock.calls.at(-1); const [command, argsIncludingFile = []] = lastCall ?? [""]; @@ -88,14 +88,14 @@ describe("getDiffForFile", () => { }); it("should use HEAD when no commit was defined", () => { - mockedChildProcess.execFileSync.mockReturnValueOnce(Buffer.from(hunks)); + mockedChildProcess.execFileSync.mockReturnValueOnce(hunks); process.env.ESLINT_PLUGIN_DIFF_COMMIT = undefined; - const diffFromFile = getDiffForFile("./mockfile.js", false); + const diffFromFile = getDiffForFile("./mockfile.js", "working"); const expectedCommand = "git"; const expectedArgs = - "diff --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --unified=0 HEAD"; + "diff-index --diff-algorithm=histogram --diff-filter=ACM --find-renames=100% --no-ext-diff --relative --unified=0 HEAD"; const lastCall = mockedChildProcess.execFileSync.mock.calls.at(-1); const [command, argsIncludingFile = []] = lastCall ?? [""]; @@ -120,7 +120,7 @@ describe("hasCleanIndex", () => { it("returns true otherwise", () => { jest.mock("child_process").resetAllMocks(); - mockedChildProcess.execFileSync.mockReturnValue(Buffer.from("")); + mockedChildProcess.execFileSync.mockReturnValue(""); expect(hasCleanIndex("")).toEqual(true); expect(mockedChildProcess.execFileSync).toHaveBeenCalled(); }); @@ -129,11 +129,9 @@ describe("hasCleanIndex", () => { describe("getDiffFileList", () => { it("should get the list of staged files", () => { jest.mock("child_process").resetAllMocks(); - mockedChildProcess.execFileSync.mockReturnValueOnce( - Buffer.from(diffFileList) - ); + mockedChildProcess.execFileSync.mockReturnValueOnce(diffFileList); expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(0); - const fileListA = getDiffFileList(false); + const fileListA = getDiffFileList("working"); expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(1); expect(fileListA).toEqual( @@ -145,18 +143,13 @@ describe("getDiffFileList", () => { describe("getUntrackedFileList", () => { it("should get the list of untracked files", () => { jest.mock("child_process").resetAllMocks(); - mockedChildProcess.execFileSync.mockReturnValueOnce( - Buffer.from(diffFileList) - ); + mockedChildProcess.execFileSync.mockReturnValueOnce(diffFileList); expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(0); - const fileListA = getUntrackedFileList(false); + const fileListA = getUntrackedFileList("working"); expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(1); - mockedChildProcess.execFileSync.mockReturnValueOnce( - Buffer.from(diffFileList) - ); - const staged = false; - const fileListB = getUntrackedFileList(staged); + mockedChildProcess.execFileSync.mockReturnValueOnce(diffFileList); + const fileListB = getUntrackedFileList("working"); // `getUntrackedFileList` uses a cache, so the number of calls to // `execFileSync` will not have increased. expect(mockedChildProcess.execFileSync).toHaveBeenCalledTimes(1); @@ -168,7 +161,10 @@ describe("getUntrackedFileList", () => { }); it("should not get a list when looking when using staged", () => { - const staged = true; - expect(getUntrackedFileList(staged)).toEqual([]); + expect(getUntrackedFileList("staged")).toEqual([]); + }); + + it("should not get a list when looking when using committed", () => { + expect(getUntrackedFileList("committed")).toEqual([]); }); }); diff --git a/src/git.ts b/src/git.ts index 747f59c..604255f 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,50 +2,48 @@ import * as child_process from "child_process"; import { resolve } from "path"; import { Range } from "./Range"; +export type DiffType = "staged" | "committed" | "working"; + const COMMAND = "git"; -const OPTIONS = { maxBuffer: 1024 * 1024 * 100 }; +const OPTIONS = { encoding: "utf8" as const, maxBuffer: 1024 * 1024 * 100 }; -const getDiffForFile = (filePath: string, staged: boolean): string => { +const getDiffForFile = (filePath: string, diffType: DiffType): string => { const args = [ - "diff", + diffType === "committed" ? "diff-tree" : "diff-index", "--diff-algorithm=histogram", "--diff-filter=ACM", "--find-renames=100%", "--no-ext-diff", "--relative", - staged && "--staged", + diffType === "staged" && "--cached", "--unified=0", process.env.ESLINT_PLUGIN_DIFF_COMMIT ?? "HEAD", + diffType === "committed" && "HEAD", "--", resolve(filePath), - ].reduce( - (acc, cur) => (typeof cur === "string" ? [...acc, cur] : acc), - [] - ); + ].filter((cur): cur is string => typeof cur === "string"); - return child_process.execFileSync(COMMAND, args, OPTIONS).toString(); + return child_process.execFileSync(COMMAND, args, OPTIONS); }; -const getDiffFileList = (staged: boolean): string[] => { +const getDiffFileList = (diffType: DiffType): string[] => { const args = [ - "diff", + diffType === "committed" ? "diff-tree" : "diff-index", "--diff-algorithm=histogram", "--diff-filter=ACM", "--find-renames=100%", "--name-only", "--no-ext-diff", "--relative", - staged && "--staged", + diffType === "staged" && "--cached", + diffType === "committed" && "-r", process.env.ESLINT_PLUGIN_DIFF_COMMIT ?? "HEAD", + diffType === "committed" && "HEAD", "--", - ].reduce( - (acc, cur) => (typeof cur === "string" ? [...acc, cur] : acc), - [] - ); + ].filter((cur): cur is string => typeof cur === "string"); return child_process .execFileSync(COMMAND, args, OPTIONS) - .toString() .trim() .split("\n") .map((filePath) => resolve(filePath)); @@ -53,7 +51,7 @@ const getDiffFileList = (staged: boolean): string[] => { const hasCleanIndex = (filePath: string): boolean => { const args = [ - "diff", + "diff-files", "--no-ext-diff", "--quiet", "--relative", @@ -71,6 +69,27 @@ const hasCleanIndex = (filePath: string): boolean => { return true; }; +const hasCleanTree = (filePath: string): boolean => { + const args = [ + "diff-index", + "--no-ext-diff", + "--quiet", + "--relative", + "--unified=0", + "HEAD", + "--", + resolve(filePath), + ]; + + try { + child_process.execFileSync(COMMAND, args, OPTIONS); + } catch (err: unknown) { + return false; + } + + return true; +}; + const fetchFromOrigin = (branch: string) => { const args = ["fetch", "--quiet", "origin", branch]; @@ -79,10 +98,10 @@ const fetchFromOrigin = (branch: string) => { let untrackedFileListCache: string[] | undefined; const getUntrackedFileList = ( - staged: boolean, + diffType: DiffType, shouldRefresh = false ): string[] => { - if (staged) { + if (diffType !== "working") { return []; } @@ -91,7 +110,6 @@ const getUntrackedFileList = ( untrackedFileListCache = child_process .execFileSync(COMMAND, args, OPTIONS) - .toString() .trim() .split("\n") .map((filePath) => resolve(filePath)); @@ -155,6 +173,13 @@ const getRangesForDiff = (diff: string): Range[] => return [...ranges, range]; }, []); +const readFileFromGit = (filePath: string) => { + const getBlob = ["ls-tree", "--object-only", "HEAD", resolve(filePath)]; + const blob = child_process.execFileSync(COMMAND, getBlob, OPTIONS).trim(); + const catFile = ["cat-file", "blob", blob]; + return child_process.execFileSync(COMMAND, catFile, OPTIONS); +}; + export { fetchFromOrigin, getDiffFileList, @@ -162,4 +187,6 @@ export { getRangesForDiff, getUntrackedFileList, hasCleanIndex, + hasCleanTree, + readFileFromGit, }; diff --git a/src/index-ci.test.ts b/src/index-ci.test.ts index 5d1e149..59d2c03 100644 --- a/src/index-ci.test.ts +++ b/src/index-ci.test.ts @@ -4,9 +4,7 @@ import * as child_process from "child_process"; jest.mock("child_process"); const mockedChildProcess = jest.mocked(child_process, { shallow: true }); -mockedChildProcess.execFileSync.mockReturnValue( - Buffer.from("line1\nline2\nline3") -); +mockedChildProcess.execFileSync.mockReturnValue("line1\nline2\nline3"); import "./index"; diff --git a/src/index.test.ts b/src/index.test.ts index c522908..80bf30a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,9 +3,7 @@ import * as child_process from "child_process"; jest.mock("child_process"); const mockedChildProcess = jest.mocked(child_process, { shallow: true }); -mockedChildProcess.execFileSync.mockReturnValue( - Buffer.from("line1\nline2\nline3") -); +mockedChildProcess.execFileSync.mockReturnValue("line1\nline2\nline3"); import { configs, processors } from "./index"; diff --git a/src/index.ts b/src/index.ts index 2b493d1..90e9016 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,14 +5,17 @@ import { diffConfig, staged, stagedConfig, + committed, + committedConfig, } from "./processors"; const configs = { ci: ciConfig, diff: diffConfig, staged: stagedConfig, + committed: committedConfig, }; -const processors = { ci, diff, staged }; +const processors = { ci, diff, staged, committed }; module.exports = { configs, processors }; diff --git a/src/processors.ts b/src/processors.ts index 8b63e79..00d6b07 100644 --- a/src/processors.ts +++ b/src/processors.ts @@ -1,5 +1,6 @@ import type { Linter } from "eslint"; import { guessBranch } from "./ci"; +import type { DiffType } from "./git"; import { fetchFromOrigin, getDiffFileList, @@ -7,6 +8,8 @@ import { getRangesForDiff, getUntrackedFileList, hasCleanIndex, + hasCleanTree, + readFileFromGit, } from "./git"; import type { Range } from "./Range"; @@ -28,19 +31,29 @@ if (process.env.CI !== undefined) { * This is increasingly useful the more files there are in the repository. */ const getPreProcessor = - (diffFileList: string[], staged: boolean) => + (diffFileList: string[], diffType: DiffType) => (text: string, filename: string) => { - let untrackedFileList = getUntrackedFileList(staged); + let untrackedFileList = getUntrackedFileList(diffType); const shouldRefresh = - !diffFileList.includes(filename) && !untrackedFileList.includes(filename); + diffType === "working" && + !diffFileList.includes(filename) && + !untrackedFileList.includes(filename); if (shouldRefresh) { - untrackedFileList = getUntrackedFileList(staged, true); + untrackedFileList = getUntrackedFileList(diffType, true); } const shouldBeProcessed = process.env.VSCODE_PID !== undefined || diffFileList.includes(filename) || untrackedFileList.includes(filename); + if ( + diffType === "committed" && + shouldBeProcessed && + !hasCleanTree(filename) + ) { + return [readFileFromGit(filename)]; + } + return shouldBeProcessed ? [text] : []; }; @@ -72,7 +85,7 @@ const getUnstagedChangesError = (filename: string): [Linter.LintMessage] => { }; const getPostProcessor = - (staged: boolean) => + (diffType: DiffType) => ( messages: Linter.LintMessage[][], filename: string @@ -81,18 +94,18 @@ const getPostProcessor = // No need to filter, just return return []; } - const untrackedFileList = getUntrackedFileList(staged); + const untrackedFileList = getUntrackedFileList(diffType); if (untrackedFileList.includes(filename)) { // We don't need to filter the messages of untracked files because they // would all be kept anyway, so we return them as-is. return messages.flat(); } - if (staged && !hasCleanIndex(filename)) { + if (diffType === "staged" && !hasCleanIndex(filename)) { return getUnstagedChangesError(filename); } - const rangesForDiff = getRangesForDiff(getDiffForFile(filename, staged)); + const rangesForDiff = getRangesForDiff(getDiffForFile(filename, diffType)); return messages.flatMap((message) => { const filteredMessage = message.filter(({ fatal, line }) => { @@ -111,24 +124,28 @@ const getPostProcessor = }); }; -type ProcessorType = "diff" | "staged" | "ci"; +type ProcessorType = "diff" | "committed" | "staged" | "ci"; const getProcessors = ( processorType: ProcessorType ): Required => { - const staged = processorType === "staged"; - const diffFileList = getDiffFileList(staged); + const diffType = + processorType === "staged" || processorType === "committed" + ? processorType + : "working"; + const diffFileList = getDiffFileList(diffType); return { - preprocess: getPreProcessor(diffFileList, staged), - postprocess: getPostProcessor(staged), - supportsAutofix: true, + preprocess: getPreProcessor(diffFileList, diffType), + postprocess: getPostProcessor(diffType), + supportsAutofix: processorType !== "committed", }; }; const ci = process.env.CI !== undefined ? getProcessors("ci") : {}; const diff = getProcessors("diff"); const staged = getProcessors("staged"); +const committed = getProcessors("committed"); const diffConfig: Linter.BaseConfig = { plugins: ["diff"], @@ -163,6 +180,16 @@ const stagedConfig: Linter.BaseConfig = { ], }; +const committedConfig: Linter.BaseConfig = { + plugins: ["diff"], + overrides: [ + { + files: ["*"], + processor: "diff/committed", + }, + ], +}; + export { ci, ciConfig, @@ -170,5 +197,7 @@ export { diffConfig, staged, stagedConfig, + committed, + committedConfig, getUnstagedChangesError, };