diff --git a/tests/git.test.ts b/tests/git.test.ts index a7b6c6c..f50e666 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -29,22 +29,38 @@ async function setupGit(cwd: string) { describe("getFileChanges", () => { it("should get changes since a specific ref", async () => { await using fixture = await createFixture({ - "foo.txt": "Hello, world!", + ".gitignore": ".env\nignored", + "a.txt": "Hello, world!", + "b.txt": "Hello, world!", }); await setupGit(fixture.path); - await fixture.rm("foo.txt"); - await fixture.writeFile("bar.txt", "This is a new file!"); + await fixture.writeFile("a.txt", "This is an updated file!"); + await fixture.rm("b.txt"); + await fixture.writeFile("c.txt", "This is a new file!"); + await fixture.mkdir("nested"); + await fixture.writeFile("nested/file.txt", "This is a nested file"); + await fixture.mkdir("ignored"); + await fixture.writeFile("ignored/file.txt", "This file should be ignored"); + await fixture.writeFile(".env", "This file should be ignored"); const result = await getFileChanges(fixture.path, fixture.path, "HEAD"); expect(result).toEqual({ additions: [ { - path: "bar.txt", - contents: await fixture.readFile("bar.txt", "base64"), + path: "a.txt", + contents: await fixture.readFile("a.txt", "base64"), + }, + { + path: "c.txt", + contents: await fixture.readFile("c.txt", "base64"), + }, + { + path: "nested/file.txt", + contents: await fixture.readFile("nested/file.txt", "base64"), }, ], - deletions: [{ path: "foo.txt" }], + deletions: [{ path: "b.txt" }], }); }); @@ -105,4 +121,111 @@ describe("getFileChanges", () => { deletions: [{ path: "nested/foo.txt" }], }); }); + + it("should allow existing symlinks", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + "bar.txt": "Hello, world!", + }); + await setupGit(fixture.path); + + await fixture.mkdir("some-dir"); + await fs.symlink( + fixture.getPath("foo.txt"), + fixture.getPath("some-dir/nested"), + ); + await exec("git", ["add", "."], { nodeOptions: { cwd: fixture.path } }); + await exec("git", ["commit", "-m", "Add symlink"], { + nodeOptions: { cwd: fixture.path }, + }); + + // Since we committed, HEAD points to the last commit and there's no change since then + const result = await getFileChanges(fixture.path, fixture.path, "HEAD"); + expect(result).toEqual({ additions: [], deletions: [] }); + + await fixture.rm("some-dir/nested"); + await fs.symlink( + fixture.getPath("bar.txt"), + fixture.getPath("some-dir/nested"), + ); + + // We made symlink changes since the last commit, so this should error now + await expect( + getFileChanges(fixture.path, fixture.path, "HEAD"), + ).rejects.toThrow( + "Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore", + ); + }); + + it("should not error when symlink is present but ignored", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + }); + await setupGit(fixture.path); + + await fixture.writeFile(".gitignore", "some-dir"); + await exec("git", ["add", "."], { nodeOptions: { cwd: fixture.path } }); + await exec("git", ["commit", "-m", "Add gitignore"], { + nodeOptions: { cwd: fixture.path }, + }); + + await fixture.mkdir("some-dir"); + await fs.symlink( + fixture.getPath("foo.txt"), + fixture.getPath("some-dir/nested"), + ); + + const result = await getFileChanges(fixture.path, fixture.path, "HEAD"); + expect(result).toEqual({ additions: [], deletions: [] }); + }); + + it("should throw error when symlink is present with non-existent path", async () => { + await using fixture = await createFixture(); + await setupGit(fixture.path); + + await fixture.mkdir("some-dir"); + await fs.symlink( + fixture.getPath("non-existent"), + fixture.getPath("some-dir/nested"), + ); + + await expect( + getFileChanges(fixture.path, fixture.path, "HEAD"), + ).rejects.toThrow( + "Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore", + ); + }); + + it("should throw error when symlink is present with existing path", async () => { + await using fixture = await createFixture({ + "foo.txt": "Hello, world!", + }); + await setupGit(fixture.path); + + await fixture.mkdir("some-dir"); + await fs.symlink( + fixture.getPath("foo.txt"), + fixture.getPath("some-dir/nested"), + ); + + await expect( + getFileChanges(fixture.path, fixture.path, "HEAD"), + ).rejects.toThrow( + "Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore", + ); + }); + + it("should throw error when executable file is present", async () => { + await using fixture = await createFixture(); + await setupGit(fixture.path); + + await fixture.writeFile("executable-file.sh", "#!/bin/bash\necho hello"); + await fs.chmod(fixture.getPath("executable-file.sh"), 0o755); + + await expect( + getFileChanges(fixture.path, fixture.path, "HEAD"), + ).rejects.toThrow( + "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", + ); + }); }); diff --git a/tests/integration/git.test.ts b/tests/integration/git.test.ts deleted file mode 100644 index 2116dba..0000000 --- a/tests/integration/git.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { execFile } from "child_process"; -import fs from "fs"; -import path from "path"; -import { getOctokit } from "@actions/github"; -import git from "isomorphic-git"; -import { afterAll, describe, expect, it, vi } from "vitest"; -import { commitChangesFromRepo } from "../../src/git.ts"; -import { getRefTreeQuery } from "../../src/github/graphql/queries.ts"; -import { - ENV, - REPO, - ROOT_TEMP_DIRECTORY, - ROOT_TEST_BRANCH_PREFIX, - log, -} from "./env.ts"; -import { deleteBranches, waitForGitHubToBeReady } from "./util.ts"; - -const octokit = getOctokit(ENV.GITHUB_TOKEN); - -const TEST_BRANCH_PREFIX = `${ROOT_TEST_BRANCH_PREFIX}-git`; - -const expectBranchHasFile = async ({ - branch, - path, - oid, -}: { - branch: string; - path: string; - oid: string | null; -}) => { - if (oid === null) { - await expect(() => - getRefTreeQuery(octokit, { - ...REPO, - ref: `refs/heads/${branch}`, - path, - }), - ).rejects.toThrow("Could not resolve file for path"); - return; - } - const ref = ( - await getRefTreeQuery(octokit, { - ...REPO, - ref: `refs/heads/${branch}`, - path, - }) - ).repository?.ref?.target; - - if (!ref) { - throw new Error("Unexpected missing ref"); - } - - if ("tree" in ref) { - expect(ref.file?.oid ?? null).toEqual(oid); - } else { - throw new Error("Expected ref to have a tree"); - } -}; - -const expectParentHasOid = async ({ - branch, - oid, -}: { - branch: string; - oid: string; -}) => { - const commit = ( - await getRefTreeQuery(octokit, { - ...REPO, - ref: `refs/heads/${branch}`, - path: "README.md", - }) - ).repository?.ref?.target; - - if (!commit || !("parents" in commit)) { - throw new Error("Expected commit to have a parent"); - } - - expect(commit.parents.nodes).toEqual([{ oid }]); -}; - -async function makeFileChanges( - changegroup: - | "standard" - | "with-executable-file" - | "with-ignored-symlink" - | "with-included-valid-symlink" - | "with-included-invalid-symlink" - | "with-unchanged-symlink" - | "with-changed-symlink", - { dir, branch }: { dir: string; branch: string }, -): Promise { - const repoDirectory = dir; - // Update an existing file - await fs.promises.writeFile( - path.join(repoDirectory, "LICENSE"), - "This is a new license", - ); - // Remove a file - await fs.promises.rm(path.join(repoDirectory, "package.json")); - // Remove a file nested in a directory - await fs.promises.rm(path.join(repoDirectory, "src", "index.ts")); - // Add a new file - await fs.promises.writeFile( - path.join(repoDirectory, "new-file.txt"), - "This is a new file", - ); - // Add new files nested in a directory - await fs.promises.mkdir(path.join(repoDirectory, "nested"), { - recursive: true, - }); - await fs.promises.writeFile( - path.join(repoDirectory, "nested", "nested-file.txt"), - "This is a nested file", - ); - await fs.promises.writeFile( - path.join(repoDirectory, "nested", "nested-file-2.md"), - "This is a nested file", - ); - // Add files that should be ignored - await fs.promises.writeFile( - path.join(repoDirectory, ".env"), - "This file should be ignored", - ); - await fs.promises.mkdir(path.join(repoDirectory, "coverage", "foo"), { - recursive: true, - }); - await fs.promises.writeFile( - path.join(repoDirectory, "coverage", "foo", "bar"), - "This file should be ignored", - ); - if (changegroup === "with-executable-file") { - // Add an executable file - await fs.promises.writeFile( - path.join(repoDirectory, "executable-file.sh"), - "#!/bin/bash\necho hello", - ); - await fs.promises.chmod( - path.join(repoDirectory, "executable-file.sh"), - 0o755, - ); - } - if (changegroup === "with-ignored-symlink") { - // node_modules is ignored in this repo - await fs.promises.mkdir(path.join(repoDirectory, "node_modules"), { - recursive: true, - }); - await fs.promises.symlink( - path.join(repoDirectory, "non-existent"), - path.join(repoDirectory, "node_modules", "nested"), - ); - } - if (changegroup === "with-included-valid-symlink") { - await fs.promises.mkdir(path.join(repoDirectory, "some-dir"), { - recursive: true, - }); - await fs.promises.symlink( - path.join(repoDirectory, "README.md"), - path.join(repoDirectory, "some-dir", "nested"), - ); - } - if (changegroup === "with-included-invalid-symlink") { - await fs.promises.mkdir(path.join(repoDirectory, "some-dir"), { - recursive: true, - }); - await fs.promises.symlink( - path.join(repoDirectory, "non-existent"), - path.join(repoDirectory, "some-dir", "nested"), - ); - } - if ( - changegroup === "with-unchanged-symlink" || - changegroup === "with-changed-symlink" - ) { - await fs.promises.mkdir(path.join(repoDirectory, "some-dir"), { - recursive: true, - }); - await fs.promises.symlink( - path.join(repoDirectory, "README.md"), - path.join(repoDirectory, "some-dir", "nested"), - ); - await git.setConfig({ - fs, - dir: repoDirectory, - path: "user.email", - value: "test@test.com", - }); - await git.setConfig({ - fs, - dir: repoDirectory, - path: "user.name", - value: "Test", - }); - await git.add({ - fs, - dir: repoDirectory, - filepath: "some-dir/nested", - }); - await git.commit({ - fs, - dir: repoDirectory, - message: "Add symlink", - author: { name: "Test", email: "test@test.com" }, - }); - - // Push the commit with symlink to GitHub so the API can use it as base. - // Using native git since isomorphic-git push requires explicit auth setup. - // Note: origin points to the local clone source (process.cwd()), not GitHub, - // so we push directly to GitHub using the token. - const githubUrl = `https://x-access-token:${ENV.GITHUB_TOKEN}@github.com/${REPO.owner}/${REPO.repo}.git`; - await new Promise((resolve, reject) => { - const p = execFile( - "git", - ["push", githubUrl, `HEAD:refs/heads/${branch}`], - { cwd: repoDirectory }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - if (changegroup === "with-changed-symlink") { - await fs.promises.rm(path.join(repoDirectory, "some-dir", "nested")); - await fs.promises.symlink( - path.join(repoDirectory, "LICENSE"), - path.join(repoDirectory, "some-dir", "nested"), - ); - } - } -} - -const makeFileChangeAssertions = async (branch: string) => { - // Expect the deleted files to not exist - await expectBranchHasFile({ branch, path: "package.json", oid: null }); - await expectBranchHasFile({ branch, path: "src/index.ts", oid: null }); - // Expect updated file to have new oid - await expectBranchHasFile({ - branch, - path: "LICENSE", - oid: "8dd03bb8a1d83212f3667bd2eb8b92746120ab8f", - }); - // Expect new files to have correct oid - await expectBranchHasFile({ - branch, - path: "new-file.txt", - oid: "be5b944ff55ca7569cc2ae34c35b5bda8cd5d37e", - }); - await expectBranchHasFile({ - branch, - path: "nested/nested-file.txt", - oid: "60eb5af9a0c03dc16dc6d0bd9a370c1aa4e095a3", - }); - await expectBranchHasFile({ - branch, - path: "nested/nested-file-2.md", - oid: "60eb5af9a0c03dc16dc6d0bd9a370c1aa4e095a3", - }); - // 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[] = []; - - // Set timeout to 1 minute - vi.setConfig({ testTimeout: 60 * 1000 }); - - describe("commitChangesFromRepo", () => { - const testDir = path.join(ROOT_TEMP_DIRECTORY, "commitChangesFromRepo"); - - for (const group of ["standard", "with-ignored-symlink"] as const) { - it(`should correctly commit all changes for group: ${group}`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-multiple-changes-${group}`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, `repo-1-${group}`); - - // 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-1-${group}`], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges(group, { dir: repoDirectory, branch }); - - // Push the changes - await commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - }); - - await waitForGitHubToBeReady(); - - await makeFileChangeAssertions(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 }); - }); - } - - it(`should allow unchanged symlinks without throwing`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-unchanged-symlink`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, `repo-unchanged-symlink`); - - await new Promise((resolve, reject) => { - const p = execFile( - "git", - ["clone", process.cwd(), `repo-unchanged-symlink`], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("with-unchanged-symlink", { - dir: repoDirectory, - branch, - }); - - await waitForGitHubToBeReady(); - - await commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - }); - - await waitForGitHubToBeReady(); - await makeFileChangeAssertions(branch); - }); - - it(`should throw error when symlink is changed`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-changed-symlink`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, `repo-changed-symlink`); - - await new Promise((resolve, reject) => { - const p = execFile( - "git", - ["clone", process.cwd(), `repo-changed-symlink`], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("with-changed-symlink", { - dir: repoDirectory, - branch, - }); - - await expect(() => - commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - }), - ).rejects.toThrow( - "Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore", - ); - }); - - describe(`should throw appropriate error when symlink is present`, () => { - it(`and file does not exist`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-invalid-symlink-error`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, `repo-invalid-symlink`); - - // 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-invalid-symlink`], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("with-included-invalid-symlink", { - dir: repoDirectory, - branch, - }); - - // Push the changes - await expect(() => - commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - }), - ).rejects.toThrow( - "Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore", - ); - }); - - it(`and file exists`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-valid-symlink-error`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, `repo-valid-symlink`); - - // 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-valid-symlink`], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("with-included-valid-symlink", { - dir: repoDirectory, - branch, - }); - - // Push the changes - await expect(() => - commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - }), - ).rejects.toThrow( - "Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore", - ); - }); - }); - - it(`should throw appropriate error when executable file is present`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-executable-file`; - branches.push(branch); - - await fs.promises.mkdir(testDir, { recursive: true }); - const repoDirectory = path.join(testDir, `repo-executable-file`); - - // 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-executable-file`], - { cwd: testDir }, - (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); - p.stdout?.pipe(process.stdout); - p.stderr?.pipe(process.stderr); - }); - - await makeFileChanges("with-executable-file", { - dir: repoDirectory, - branch, - }); - - // Push the changes - await expect(() => - commitChangesFromRepo({ - octokit, - ...REPO, - branch, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - cwd: repoDirectory, - log, - }), - ).rejects.toThrow( - "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", - ); - }); - }); - - afterAll(async () => { - console.info("Cleaning up test branches"); - - await deleteBranches(octokit, branches); - }); -});