diff --git a/package.json b/package.json index 86ec4b9..b8a53d5 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,14 @@ "@graphql-codegen/cli": "^7.1.2", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.9.1", + "fs-fixture": "^2.14.0", "oxfmt": "^0.53.0", "oxlint": "^1.68.0", "oxlint-tsgolint": "^0.23.0", "pino": "^9.3.2", "pino-pretty": "^11.2.2", "publint": "^0.3.21", + "tinyexec": "^1.2.4", "tsdown": "^0.22.1", "typescript": "^6.0.3", "vitest": "^4.1.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce4d95a..e32e922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@types/node': specifier: ^25.9.1 version: 25.9.1 + fs-fixture: + specifier: ^2.14.0 + version: 2.14.0 oxfmt: specifier: ^0.53.0 version: 0.53.0 @@ -52,6 +55,9 @@ importers: publint: specifier: ^0.3.21 version: 0.3.21 + tinyexec: + specifier: ^1.2.4 + version: 1.2.4 tsdown: specifier: ^0.22.1 version: 0.22.1(publint@0.3.21)(typescript@6.0.3) @@ -1594,6 +1600,10 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-fixture@2.14.0: + resolution: {integrity: sha512-lZBaSOewY5snBSyOjn3fmo2ES5gOR7Q1Y6dILH0/xR0xejjUhux65OGaFWhQ+jiyvTi5HOHqXQrQ9v48Lsmqbw==} + engines: {node: '>=18.0.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4327,6 +4337,8 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-fixture@2.14.0: {} + fsevents@2.3.3: optional: true diff --git a/src/fs.ts b/src/fs.ts index cc07bd2..599215d 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,32 +1,37 @@ import { promises as fs } from "fs"; import * as path from "path"; +import { commitFilesFromBase64 } from "./core.ts"; import type { - CommitFilesFromBuffersArgs, + CommitFilesFromBase64Args, CommitFilesFromDirectoryArgs, CommitFilesResult, } from "./interface.ts"; -import { commitFilesFromBuffers } from "./node.ts"; -export const commitFilesFromDirectory = async ({ +export async function commitFilesFromDirectory({ cwd, fileChanges, ...otherArgs -}: CommitFilesFromDirectoryArgs): Promise => { - const additions: CommitFilesFromBuffersArgs["fileChanges"]["additions"] = - await Promise.all( - (fileChanges.additions || []).map(async (p) => { - return { - path: p, - contents: await fs.readFile(path.join(cwd, p)), - }; - }), - ); - - return commitFilesFromBuffers({ +}: CommitFilesFromDirectoryArgs): Promise { + return await commitFilesFromBase64({ ...otherArgs, - fileChanges: { - additions, - deletions: fileChanges.deletions, - }, + fileChanges: await normalizeFileChanges(fileChanges, cwd), }); -}; +} + +// Exported for testing only +export async function normalizeFileChanges( + fileChanges: CommitFilesFromDirectoryArgs["fileChanges"], + cwd: string, +): Promise { + return { + additions: fileChanges.additions + ? await Promise.all( + fileChanges.additions.map(async (a) => ({ + path: a, + contents: await fs.readFile(path.join(cwd, a), "base64"), + })), + ) + : undefined, + deletions: fileChanges.deletions?.map((d) => ({ path: d })), + }; +} diff --git a/src/git.ts b/src/git.ts index 01fe080..a8aa1d7 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,12 +1,12 @@ import { promises as fs } from "fs"; import { relative, resolve } from "path"; import git from "isomorphic-git"; +import { commitFilesFromBase64 } from "./core.ts"; import type { CommitChangesFromRepoArgs, - CommitFilesFromBuffersArgs, + CommitFilesFromBase64Args, CommitFilesResult, } from "./interface.ts"; -import { commitFilesFromBuffers } from "./node.ts"; /** * @see https://isomorphic-git.org/docs/en/walk#walkerentry-mode @@ -23,7 +23,6 @@ export const commitChangesFromRepo = async ({ cwd: workingDirectory, recursivelyFindRoot = true, filterFiles, - log, ...otherArgs }: CommitChangesFromRepoArgs): Promise => { const ref = base?.commit ?? "HEAD"; @@ -44,6 +43,27 @@ export const commitChangesFromRepo = async ({ throw new Error(`Could not determine oid for ${ref}`); } + return await commitFilesFromBase64({ + ...otherArgs, + fileChanges: await getFileChanges( + workingDirectory, + repoRoot, + oid, + filterFiles, + ), + base: { + commit: oid, + }, + }); +}; + +// Exported for testing only +export async function getFileChanges( + cwd: string, + repoRoot: string, + ref: string, + filterFiles?: CommitChangesFromRepoArgs["filterFiles"], +): Promise { /** * The directory to add files from. This is relative to the repository * root, and is used to filter files. @@ -52,13 +72,10 @@ export const commitChangesFromRepo = async ({ cwd === repoRoot ? null : relative(repoRoot, cwd) + "/"; // Determine changed files - const trees = [git.TREE({ ref: oid }), git.WORKDIR()]; - const additions: CommitFilesFromBuffersArgs["fileChanges"]["additions"] = []; - const deletions: CommitFilesFromBuffersArgs["fileChanges"]["deletions"] = []; - const fileChanges = { - additions, - deletions, - }; + const trees = [git.TREE({ ref }), git.WORKDIR()]; + const additions: CommitFilesFromBase64Args["fileChanges"]["additions"] = []; + const deletions: CommitFilesFromBase64Args["fileChanges"]["deletions"] = []; + await git.walk({ fs, dir: repoRoot, @@ -115,7 +132,7 @@ export const commitChangesFromRepo = async ({ } if (!workdir) { // File was deleted - deletions.push(filepath); + deletions.push({ path: filepath }); return null; } else { // File was added / updated @@ -125,19 +142,12 @@ export const commitChangesFromRepo = async ({ } additions.push({ path: filepath, - contents: Buffer.from(arr), + contents: Buffer.from(arr).toString("base64"), }); } return true; }, }); - return commitFilesFromBuffers({ - ...otherArgs, - fileChanges, - log, - base: { - commit: oid, - }, - }); -}; + return { additions, deletions }; +} diff --git a/src/node.ts b/src/node.ts index b87f065..1103a76 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,21 +1,29 @@ import { commitFilesFromBase64 } from "./core.js"; import type { + CommitFilesFromBase64Args, CommitFilesFromBuffersArgs, CommitFilesResult, } from "./interface.ts"; -export const commitFilesFromBuffers = async ({ +export async function commitFilesFromBuffers({ fileChanges, ...otherArgs -}: CommitFilesFromBuffersArgs): Promise => { - return commitFilesFromBase64({ +}: CommitFilesFromBuffersArgs): Promise { + return await commitFilesFromBase64({ ...otherArgs, - fileChanges: { - additions: fileChanges.additions?.map(({ path, contents }) => ({ - path, - contents: contents.toString("base64"), - })), - deletions: fileChanges.deletions?.map((path) => ({ path })), - }, + fileChanges: normalizeFileChanges(fileChanges), }); -}; +} + +// Exported for testing only +export function normalizeFileChanges( + fileChanges: CommitFilesFromBuffersArgs["fileChanges"], +): CommitFilesFromBase64Args["fileChanges"] { + return { + additions: fileChanges.additions?.map((a) => ({ + path: a.path, + contents: a.contents.toString("base64"), + })), + deletions: fileChanges.deletions?.map((d) => ({ path: d })), + }; +} diff --git a/tests/fs.test.ts b/tests/fs.test.ts new file mode 100644 index 0000000..11dc4f9 --- /dev/null +++ b/tests/fs.test.ts @@ -0,0 +1,38 @@ +import { createFixture } from "fs-fixture"; +import { describe, expect, it } from "vitest"; +import { normalizeFileChanges } from "../src/fs.ts"; + +describe("normalizeFileChanges", () => { + it("should convert file contents to base64", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + }); + + const result = await normalizeFileChanges( + { + additions: ["foo.txt"], + deletions: ["bar.txt"], + }, + fixture.path, + ); + + expect(result).toEqual({ + additions: [ + { + path: "foo.txt", + contents: await fixture.readFile("foo.txt", "base64"), + }, + ], + deletions: [{ path: "bar.txt" }], + }); + }); + + it("should pass through empty file changes", async () => { + const result = await normalizeFileChanges( + { additions: [], deletions: [] }, + "/", + ); + + expect(result).toEqual({ additions: [], deletions: [] }); + }); +}); diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..a7b6c6c --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createFixture } from "fs-fixture"; +import { exec } from "tinyexec"; +import { describe, expect, it } from "vitest"; +import { getFileChanges } from "../src/git.ts"; + +async function setupGit(cwd: string) { + await exec("git", ["init"], { nodeOptions: { cwd } }); + await fs.appendFile( + path.join(cwd, ".git/config"), + `\ +[user] + email = x@y.z + name = xyz +[commit] + gpgSign = false +[tag] + gpgSign = false + forceSignAnnotated = false`, + "utf8", + ); + await exec("git", ["add", "."], { nodeOptions: { cwd } }); + await exec("git", ["commit", "-m", "initial commit", "--allow-empty"], { + nodeOptions: { cwd }, + }); +} + +describe("getFileChanges", () => { + it("should get changes since a specific ref", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + }); + await setupGit(fixture.path); + + await fixture.rm("foo.txt"); + await fixture.writeFile("bar.txt", "This is a new file!"); + + const result = await getFileChanges(fixture.path, fixture.path, "HEAD"); + expect(result).toEqual({ + additions: [ + { + path: "bar.txt", + contents: await fixture.readFile("bar.txt", "base64"), + }, + ], + deletions: [{ path: "foo.txt" }], + }); + }); + + it("should filter files with filterFiles", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + "nested/foo.txt": "Hello, world!", + }); + await setupGit(fixture.path); + + await fixture.rm("foo.txt"); + await fixture.rm("nested/foo.txt"); + await fixture.writeFile("bar.txt", "This is a new file!"); + await fixture.writeFile("nested/bar.txt", "This is a new file!"); + + const result = await getFileChanges( + fixture.path, + fixture.path, + "HEAD", + // Only include top-level files + (file) => !file.includes("/"), + ); + expect(result).toEqual({ + additions: [ + { + path: "bar.txt", + contents: await fixture.readFile("bar.txt", "base64"), + }, + ], + deletions: [{ path: "foo.txt" }], + }); + }); + + it("should filter files when running in a repository sub-directory", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + "nested/foo.txt": "Hello, world!", + }); + await setupGit(fixture.path); + + await fixture.rm("foo.txt"); + await fixture.rm("nested/foo.txt"); + await fixture.writeFile("bar.txt", "This is a new file!"); + await fixture.writeFile("nested/bar.txt", "This is a new file!"); + + const result = await getFileChanges( + path.join(fixture.path, "nested"), + fixture.path, + "HEAD", + ); + expect(result).toEqual({ + additions: [ + { + path: "nested/bar.txt", + contents: await fixture.readFile("nested/bar.txt", "base64"), + }, + ], + deletions: [{ path: "nested/foo.txt" }], + }); + }); +}); diff --git a/tests/integration/fs.test.ts b/tests/integration/fs.test.ts deleted file mode 100644 index 708a261..0000000 --- a/tests/integration/fs.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { promises as fs } from "fs"; -import * as path from "path"; -import { getOctokit } from "@actions/github"; -import { afterAll, describe, it } from "vitest"; -import { commitFilesFromDirectory } from "../../src/fs.ts"; -import { - ENV, - REPO, - ROOT_TEMP_DIRECTORY, - ROOT_TEST_BRANCH_PREFIX, - log, -} from "./env.ts"; -import { deleteBranches } from "./util.ts"; - -const octokit = getOctokit(ENV.GITHUB_TOKEN); - -const TEST_BRANCHES = { - COMMIT_FILE: `${ROOT_TEST_BRANCH_PREFIX}-fs-commit-file`, -} as const; - -describe("fs", () => { - describe("commitFilesFromDirectory", () => { - it("should commit a file", async () => { - // Create test directory - await fs.mkdir(ROOT_TEMP_DIRECTORY, { recursive: true }); - const tmpDir = await fs.mkdtemp(path.join(ROOT_TEMP_DIRECTORY, "test-")); - await fs.writeFile(path.join(tmpDir, "foo.txt"), "Hello, world!"); - - await commitFilesFromDirectory({ - octokit, - ...REPO, - branch: TEST_BRANCHES.COMMIT_FILE, - base: { - branch: "main", - }, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: tmpDir, - fileChanges: { - additions: ["foo.txt"], - }, - log, - }); - }); - }); - - afterAll(async () => { - console.info("Cleaning up test branches"); - - await deleteBranches(octokit, Object.values(TEST_BRANCHES)); - }); -}); diff --git a/tests/integration/git.test.ts b/tests/integration/git.test.ts index f96ff6b..2116dba 100644 --- a/tests/integration/git.test.ts +++ b/tests/integration/git.test.ts @@ -270,60 +270,6 @@ const makeFileChangeAssertions = async (branch: string) => { }); }; -const makeSubdirectoryFileChangeAssertions = async ( - branch: string, - includeNonTxt: boolean, -) => { - // Expect new file outside of subdir to not exist - await expectBranchHasFile({ - branch, - path: "new-file.txt", - oid: null, - }); - // Expect new files to have correct oid - await expectBranchHasFile({ - branch, - path: "nested/nested-file.txt", - oid: "60eb5af9a0c03dc16dc6d0bd9a370c1aa4e095a3", - }); - await expectBranchHasFile({ - branch, - path: "nested/nested-file-2.md", - oid: includeNonTxt ? "60eb5af9a0c03dc16dc6d0bd9a370c1aa4e095a3" : null, - }); - // Expect ignored files to not exist - await expectBranchHasFile({ branch, path: ".env", oid: null }); - await expectBranchHasFile({ - branch, - path: "coverage/foo/bar", - oid: null, - }); -}; - -const makeFilteredFileChangeAssertions = async (branch: string) => { - // Expect the deleted files to not exist - await expectBranchHasFile({ branch, path: "package.json", oid: null }); - // Expect new files to have correct oid - await expectBranchHasFile({ - branch, - path: "new-file.txt", - oid: "be5b944ff55ca7569cc2ae34c35b5bda8cd5d37e", - }); - // Expect filtered-out file to not exist - await expectBranchHasFile({ - branch, - path: "nested/nested-file.txt", - oid: null, - }); - // Expect ignored files to not exist - await expectBranchHasFile({ branch, path: ".env", oid: null }); - await expectBranchHasFile({ - branch, - path: "coverage/foo/bar", - oid: null, - }); -}; - describe("git", () => { const branches: string[] = []; @@ -631,266 +577,6 @@ describe("git", () => { "Unexpected executable file at executable-file.sh, GitHub API only supports non-executable files and directories. You may need to add this file to .gitignore", ); }); - - it("should correctly be able to base changes off specific commit", async () => { - const branch = `${TEST_BRANCH_PREFIX}-specific-base`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, "repo-2"); - - // Clone the git repo locally usig the git cli and child-process - await new Promise((resolve, reject) => { - const p = execFile( - "git", - ["clone", process.cwd(), "repo-2"], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("standard", { dir: repoDirectory, branch }); - - // Determine the previous commit hash - const gitLog = await git.log({ - fs, - dir: repoDirectory, - ref: "HEAD", - depth: 2, - }); - - const oid = gitLog[1]?.oid ?? ""; - - // Push the changes - await commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - base: { - commit: oid, - }, - }); - - await waitForGitHubToBeReady(); - - await makeFileChangeAssertions(branch); - - await expectParentHasOid({ branch, oid }); - }); - - it(`filterFiles should correctly filter files`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-root-repodirectory-unspecified-filter`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join( - testDir, - `repo-root-repodirectory-unspecified-filter`, - ); - - // Clone the git repo locally using the git cli and child-process - await new Promise((resolve, reject) => { - const p = execFile( - "git", - [ - "clone", - process.cwd(), - `repo-root-repodirectory-unspecified-filter`, - ], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("standard", { dir: repoDirectory, branch }); - - // Push the changes - await commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - // Only include top-level files - filterFiles: (file) => !file.includes("/"), - log, - }); - - await waitForGitHubToBeReady(); - - await makeFilteredFileChangeAssertions(branch); - - // Expect the OID to be the HEAD commit - const oid = - ( - await git.log({ - fs, - dir: repoDirectory, - ref: "HEAD", - depth: 1, - }) - )[0]?.oid ?? "NO_OID"; - - await expectParentHasOid({ branch, oid }); - }); - - describe("when running in repository sub-directory", () => { - it(`should correctly filter files to sub-directory`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-subdir-repodirectory-unspecified-add`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join( - testDir, - `repo-subdir-repodirectory-unspecified-add`, - ); - - // Clone the git repo locally using the git cli and child-process - await new Promise((resolve, reject) => { - const p = execFile( - "git", - [ - "clone", - process.cwd(), - `repo-subdir-repodirectory-unspecified-add`, - ], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("standard", { dir: repoDirectory, branch }); - - // Push the changes - await commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: path.join(repoDirectory, "nested"), - log, - }); - - await waitForGitHubToBeReady(); - - await makeSubdirectoryFileChangeAssertions(branch, true); - - // Expect the OID to be the HEAD commit - const oid = - ( - await git.log({ - fs, - dir: repoDirectory, - ref: "HEAD", - depth: 1, - }) - )[0]?.oid ?? "NO_OID"; - - await expectParentHasOid({ branch, oid }); - }); - - it(`filterFiles should correctly filter files`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-subdir-repodirectory-unspecified-filter`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join( - testDir, - `repo-subdir-repodirectory-unspecified-filter`, - ); - - // Clone the git repo locally using the git cli and child-process - await new Promise((resolve, reject) => { - const p = execFile( - "git", - [ - "clone", - process.cwd(), - `repo-subdir-repodirectory-unspecified-filter`, - ], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("standard", { dir: repoDirectory, branch }); - - // Push the changes - await commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: path.join(repoDirectory, "nested"), - filterFiles: (file) => file.includes(".txt"), - log, - }); - - await waitForGitHubToBeReady(); - - await makeSubdirectoryFileChangeAssertions(branch, false); - - // Expect the OID to be the HEAD commit - const oid = - ( - await git.log({ - fs, - dir: repoDirectory, - ref: "HEAD", - depth: 1, - }) - )[0]?.oid ?? "NO_OID"; - - await expectParentHasOid({ branch, oid }); - }); - }); }); afterAll(async () => {