diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index ad161a9df1e..92e24e47074 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -159,7 +159,6 @@ "pathe": "1.1.2", "react": "19.2.4", "semver": "7.6.3", - "simple-git": "3.32.3", "stacktracey": "2.1.8", "strip-ansi": "7.1.0", "supports-hyperlinks": "3.1.0", diff --git a/packages/cli-kit/src/public/node/git.test.ts b/packages/cli-kit/src/public/node/git.test.ts index 383efbf1626..306bbd2e73d 100644 --- a/packages/cli-kit/src/public/node/git.test.ts +++ b/packages/cli-kit/src/public/node/git.test.ts @@ -9,38 +9,11 @@ import { readFileSync, writeFileSync, } from './fs.js' -import {hasGit} from './context/local.js' +import {hasGit, isTerminalInteractive} from './context/local.js' import {beforeEach, describe, expect, test, vi} from 'vitest' -import simpleGit from 'simple-git' - -const mockedClone = vi.fn(async () => ({current: 'Mocked'})) -const mockedInit = vi.fn(async () => {}) -const mockedCheckIsRepo = vi.fn(async () => false) -const mockedGetConfig = vi.fn(async () => ({})) -const mockedGetLog = vi.fn(async () => ({})) -const mockedCommit = vi.fn(async () => ({})) -const mockedRaw = vi.fn(async () => '') -const mockedCheckout = vi.fn(async () => ({})) -const mockedGitStatus = vi.fn(async (): Promise<{isClean: () => boolean}> => ({isClean: () => false})) -const mockedRemoveRemote = vi.fn(async () => {}) -const mockedGetRemotes = vi.fn(async () => [] as {name: string; refs: any}[]) -const mockedTags = vi.fn(async () => ({})) -const simpleGitProperties = { - clone: mockedClone, - init: mockedInit, - checkIsRepo: mockedCheckIsRepo, - getConfig: mockedGetConfig, - log: mockedGetLog, - commit: mockedCommit, - raw: mockedRaw, - checkoutLocalBranch: mockedCheckout, - status: mockedGitStatus, - tags: mockedTags, - removeRemote: mockedRemoveRemote, - getRemotes: mockedGetRemotes, -} +import {execa} from 'execa' -vi.mock('simple-git') +vi.mock('execa') vi.mock('./context/local.js') vi.mock('./fs.js', async () => { const fs = await vi.importActual('./fs.js') @@ -53,210 +26,178 @@ vi.mock('./fs.js', async () => { } }) +const mockedExeca = vi.mocked(execa) + +function mockGitCommand(stdout = '', stderr = '') { + mockedExeca.mockResolvedValue({stdout, stderr} as any) +} + +function mockGitCommandSequence(results: {stdout?: string; error?: Error}[]) { + const mutableResults = [...results] + mockedExeca.mockImplementation((() => { + const result = mutableResults.shift() + if (result?.error) throw result.error + return Promise.resolve({stdout: result?.stdout ?? '', stderr: ''}) + }) as any) +} + beforeEach(() => { vi.mocked(hasGit).mockResolvedValue(true) - vi.mocked(simpleGit).mockReturnValue(simpleGitProperties) + vi.mocked(isTerminalInteractive).mockReturnValue(true) + mockedExeca.mockReset() + mockGitCommand() }) describe('downloadRepository()', async () => { - test('calls simple-git to clone a repo without branch', async () => { - // Given + test('calls git clone without branch', async () => { const repoUrl = 'http://repoUrl' const destination = 'destination' - const options: any = {'--recurse-submodules': null} - // When await git.downloadGitRepository({repoUrl, destination}) - // Then - expect(mockedClone).toHaveBeenCalledWith(repoUrl, destination, options) + expect(mockedExeca).toHaveBeenCalledWith('git', ['clone', '--recurse-submodules', repoUrl, destination]) }) - test('calls simple-git to clone a repo with branch', async () => { - // Given + test('calls git clone with branch', async () => { const repoUrl = 'http://repoUrl#my-branch' const destination = 'destination' - const options: any = {'--recurse-submodules': null, '--branch': 'my-branch'} - // When await git.downloadGitRepository({repoUrl, destination}) - // Then - expect(mockedClone).toHaveBeenCalledWith('http://repoUrl', destination, options) + expect(mockedExeca).toHaveBeenCalledWith('git', [ + 'clone', + '--recurse-submodules', + '--branch', + 'my-branch', + 'http://repoUrl', + destination, + ]) }) test('fails when the shallow and latestTag properties are passed', async () => { await expect(async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' const shallow = true const latestTag = true - // When await git.downloadGitRepository({repoUrl, destination, shallow, latestTag}) - - // Then }).rejects.toThrowError(/Git can't clone the latest release with the 'shallow' property/) }) test('fails when the branch and latestTag properties are passed', async () => { await expect(async () => { - // Given const repoUrl = 'http://repoUrl#my-branch' const destination = 'destination' const latestTag = true - // When await git.downloadGitRepository({repoUrl, destination, latestTag}) - - // Then }).rejects.toThrowError(/Git can't clone the latest release with a 'branch'/) }) test("fails when the latestTag doesn't exist ", async () => { + mockGitCommandSequence([ + // clone + {stdout: ''}, + // describe --tags + {error: new Error('fatal: No names found')}, + ]) + await expect(async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' const latestTag = true - const mockedTags = vi.fn(async () => ({ - all: [], - latest: undefined, - })) - vi.mocked(simpleGit).mockReturnValue({ - ...simpleGitProperties, - tags: mockedTags, - }) - - // When await git.downloadGitRepository({repoUrl, destination, latestTag}) - - // Then - }).rejects.toThrowError(/Couldn't obtain the most recent tag of the repository http:\/\/repoUrl/) + }).rejects.toThrowError(/fatal: No names found/) }) - test('calls simple-git to clone a repo with branch and checkouts the latest release', async () => { - // Given + test('clones and checks out the latest tag', async () => { + mockGitCommandSequence([ + // clone + {stdout: ''}, + // describe --tags --abbrev=0 + {stdout: '1.2.3'}, + // checkout + {stdout: ''}, + ]) + const repoUrl = 'http://repoUrl' const destination = 'destination' const latestTag = true - const options: any = {'--recurse-submodules': null} - const expectedLatestTag = '1.2.3' - const mockedTags = vi.fn(async () => ({ - all: [], - latest: expectedLatestTag, - })) - const mockCheckout = vi.fn(async () => ({current: 'Mocked'})) - - vi.mocked(simpleGit).mockReturnValue({ - ...simpleGitProperties, - tags: mockedTags, - checkout: mockCheckout, - }) - // When await git.downloadGitRepository({repoUrl, destination, latestTag}) - // Then - expect(mockedClone).toHaveBeenCalledWith('http://repoUrl', destination, options) - expect(mockCheckout).toHaveBeenCalledWith(expectedLatestTag) + expect(mockedExeca).toHaveBeenCalledWith('git', ['clone', '--recurse-submodules', repoUrl, destination]) + expect(mockedExeca).toHaveBeenCalledWith('git', ['checkout', '1.2.3'], {cwd: destination}) }) test('throws when destination exists as a file', async () => { await expect(async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' vi.mocked(fileExists).mockResolvedValue(true) vi.mocked(isDirectory).mockResolvedValue(false) - // When await git.downloadGitRepository({repoUrl, destination}) - - // Then }).rejects.toThrowError(/Can't clone to/) }) test('throws when destination directory is not empty', async () => { await expect(async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' vi.mocked(fileExists).mockResolvedValue(true) vi.mocked(isDirectory).mockResolvedValue(true) vi.mocked(glob).mockResolvedValue(['file1.txt', 'file2.txt']) - // When await git.downloadGitRepository({repoUrl, destination}) - - // Then }).rejects.toThrowError(/already exists and is not empty/) }) test('throws when destination contains only hidden files', async () => { await expect(async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' vi.mocked(fileExists).mockResolvedValue(true) vi.mocked(isDirectory).mockResolvedValue(true) vi.mocked(glob).mockResolvedValue(['.git', '.DS_Store']) - // When await git.downloadGitRepository({repoUrl, destination}) - - // Then }).rejects.toThrowError(/already exists and is not empty/) }) test('succeeds when destination directory is empty', async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' - const options: any = {'--recurse-submodules': null} vi.mocked(fileExists).mockResolvedValue(true) vi.mocked(isDirectory).mockResolvedValue(true) vi.mocked(glob).mockResolvedValue([]) - // When await git.downloadGitRepository({repoUrl, destination}) - // Then - expect(mockedClone).toHaveBeenCalledWith(repoUrl, destination, options) + expect(mockedExeca).toHaveBeenCalledWith('git', ['clone', '--recurse-submodules', repoUrl, destination]) }) test('succeeds when destination does not exist', async () => { - // Given const repoUrl = 'http://repoUrl' const destination = 'destination' - const options: any = {'--recurse-submodules': null} vi.mocked(fileExists).mockResolvedValue(false) - // When await git.downloadGitRepository({repoUrl, destination}) - // Then - expect(mockedClone).toHaveBeenCalledWith(repoUrl, destination, options) + expect(mockedExeca).toHaveBeenCalledWith('git', ['clone', '--recurse-submodules', repoUrl, destination]) }) }) describe('initializeRepository()', () => { - test('calls simple-git to init a repo in the given directory', async () => { - // Given + test('calls git init and checkout in the given directory', async () => { const directory = '/tmp/git-repo' - // When await git.initializeGitRepository(directory, 'my-branch') - // Then - expect(simpleGit).toHaveBeenCalledOnce() - expect(simpleGit).toHaveBeenCalledWith({baseDir: '/tmp/git-repo'}) - - expect(mockedInit).toHaveBeenCalledOnce() - expect(mockedCheckout).toHaveBeenCalledOnce() - expect(mockedCheckout).toHaveBeenCalledWith('my-branch') + expect(mockedExeca).toHaveBeenCalledWith('git', ['init'], {cwd: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['checkout', '-b', 'my-branch'], {cwd: directory}) }) }) @@ -279,255 +220,224 @@ describe('createGitIgnore()', () => { describe('getLatestCommit()', () => { test('gets the latest commit through git log', async () => { - const latestCommit = {key: 'value'} + mockGitCommand('abc123\x002024-01-01\x00commit message\x00HEAD -> main\x00\x00John\x00john@test.com') - mockedGetLog.mockResolvedValue({latest: latestCommit, all: [latestCommit], total: 1}) - - await expect(git.getLatestGitCommit()).resolves.toBe(latestCommit) + const result = await git.getLatestGitCommit() + expect(result.hash).toBe('abc123') + expect(result.message).toBe('commit message') + expect(result.author_name).toBe('John') }) + test('throws if no latest commit is found', async () => { - mockedGetLog.mockResolvedValue({latest: null, all: [], total: 0}) + mockGitCommand('') await expect(() => git.getLatestGitCommit()).rejects.toThrowError(/Must have at least one commit to run command/) }) - test('passes the directory option to simple git', async () => { - // Given + + test('passes the directory option', async () => { const directory = '/test/directory' - const latestCommit = {key: 'value'} - mockedGetLog.mockResolvedValue({latest: latestCommit, all: [latestCommit], total: 1}) + mockGitCommand('abc123\x002024-01-01\x00msg\x00refs\x00\x00John\x00john@test.com') - // When await git.getLatestGitCommit(directory) - // Then - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', expect.arrayContaining(['log']), {cwd: directory}) }) }) describe('addAll()', () => { - test('builds valid raw command', async () => { + test('calls git add --all', async () => { const directory = '/test/directory' await git.addAllToGitFromDirectory(directory) - expect(mockedRaw).toHaveBeenCalledOnce() - expect(mockedRaw).toHaveBeenCalledWith('add', '--all') - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['add', '--all'], {cwd: directory}) }) }) describe('commit()', () => { - test('calls simple-git commit method', async () => { - mockedCommit.mockResolvedValue({commit: 'sha'}) + test('calls git commit and returns sha', async () => { + mockGitCommandSequence([ + // commit + {stdout: ''}, + // rev-parse HEAD + {stdout: 'abc123'}, + ]) const commitMsg = 'my msg' const commitSha = await git.createGitCommit(commitMsg) - expect(mockedCommit).toHaveBeenCalledOnce() - expect(mockedCommit).toHaveBeenCalledWith(commitMsg, undefined) - expect(commitSha).toBe('sha') + expect(mockedExeca).toHaveBeenCalledWith('git', ['commit', '-m', commitMsg], {cwd: undefined}) + expect(commitSha).toBe('abc123') }) - test('passes options to relevant function', async () => { + + test('passes author and directory options', async () => { const author = 'Vincent Lynch ' const directory = '/some/path' - mockedCommit.mockResolvedValue({commit: 'sha'}) + mockGitCommandSequence([ + // commit + {stdout: ''}, + // rev-parse HEAD + {stdout: 'sha'}, + ]) await git.createGitCommit('msg', {author, directory}) - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) - expect(mockedCommit).toHaveBeenCalledWith('msg', {'--author': author}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['commit', '-m', 'msg', '--author', author], {cwd: directory}) }) }) describe('getHeadSymbolicRef()', () => { test('gets git HEAD symbolic reference', async () => { const testRef = 'refs/heads/my-test-branch' - mockedRaw.mockResolvedValue(testRef) + mockGitCommand(testRef) await expect(git.getHeadSymbolicRef()).resolves.toBe(testRef) }) + test('throws if HEAD is detached', async () => { - mockedRaw.mockResolvedValue('') + mockGitCommand('') await expect(() => git.getHeadSymbolicRef()).rejects.toThrowError(/Git HEAD can't be detached to run command/) }) - test('passes the directory option to simple git', async () => { + + test('passes the directory option', async () => { const directory = '/test/directory' - mockedRaw.mockResolvedValue('ref/unit') + mockGitCommand('ref/unit') await git.getHeadSymbolicRef(directory) - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['symbolic-ref', '-q', 'HEAD'], {cwd: directory}) }) }) describe('ensurePresentOrAbort()', () => { test('throws an error if git is not present', async () => { - // Given vi.mocked(hasGit).mockResolvedValue(false) - // Then await expect(() => git.ensureGitIsPresentOrAbort()).rejects.toThrowError( /Git is necessary in the environment to continue/, ) }) test("doesn't throw an error if Git is present", async () => { - // Given vi.mocked(hasGit).mockResolvedValue(true) - // Then await expect(git.ensureGitIsPresentOrAbort()).resolves.toBeUndefined() }) }) describe('ensureInsideGitDirectory()', () => { test('throws an error if not inside a git directory', async () => { - // Given - mockedCheckIsRepo.mockResolvedValue(false) + const error = Object.assign(new Error('not a git repo'), {exitCode: 128}) + mockedExeca.mockRejectedValue(error) - // Then await expect(() => git.ensureInsideGitDirectory()).rejects.toThrowError(/is not a Git directory/) }) test("doesn't throw an error if inside a git directory", async () => { - // Given - mockedCheckIsRepo.mockResolvedValue(true) + mockGitCommand('') - // Then await expect(git.ensureInsideGitDirectory()).resolves.toBeUndefined() }) }) describe('insideGitDirectory()', () => { test('returns true if inside a git directory', async () => { - // Given - mockedCheckIsRepo.mockResolvedValue(true) + mockGitCommand('.git') - // Then await expect(git.insideGitDirectory()).resolves.toBe(true) }) test('returns false if not inside a git directory', async () => { - // Given - mockedCheckIsRepo.mockResolvedValue(false) + const error = Object.assign(new Error('not a git repo'), {exitCode: 128}) + mockedExeca.mockRejectedValue(error) - // Then await expect(git.insideGitDirectory()).resolves.toBe(false) }) - test('passes the directory option to simple git', async () => { - // Given + test('passes the directory option', async () => { const directory = '/test/directory' - mockedCheckIsRepo.mockResolvedValue(true) + mockGitCommand('.git') - // When await git.insideGitDirectory(directory) - // Then - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['rev-parse', '--git-dir'], {cwd: directory}) }) }) describe('ensureIsClean()', () => { test('throws an error if git directory is not clean', async () => { - // Given - mockedGitStatus.mockResolvedValue({isClean: () => false}) + mockGitCommand(' M file.txt') - // Then await expect(() => git.ensureIsClean()).rejects.toThrowError(/is not a clean Git directory/) }) test("doesn't throw an error if git directory is clean", async () => { - // Given - mockedGitStatus.mockResolvedValue({isClean: () => true}) + mockGitCommand('') - // Then await expect(git.ensureIsClean()).resolves.toBeUndefined() }) }) describe('getLatestTag()', () => { test('returns the latest tag from git', async () => { - // Given const expectedTag = 'v1.0.0' - mockedTags.mockResolvedValue({latest: expectedTag}) + mockGitCommand(expectedTag) - // When - const tag = await git.getLatestTag() - - // Then await expect(git.getLatestTag()).resolves.toBe(expectedTag) }) test('return undefined when no tags exist', async () => { - // Given - mockedTags.mockResolvedValue({}) - - // When - const tag = await git.getLatestTag() + const error = Object.assign(new Error('fatal: No names found'), {exitCode: 128}) + mockedExeca.mockRejectedValue(error) - // Then await expect(git.getLatestTag()).resolves.toBeUndefined() }) }) describe('isGitClean()', () => { test('return false if git directory is not clean', async () => { - // Given - mockedGitStatus.mockResolvedValue({isClean: () => false}) + mockGitCommand(' M file.txt') - // Then await expect(git.isClean()).resolves.toBe(false) }) - test('return true if git directory is not clean', async () => { - // Given - mockedGitStatus.mockResolvedValue({isClean: () => true}) + test('return true if git directory is clean', async () => { + mockGitCommand('') - // Then await expect(git.isClean()).resolves.toBe(true) }) - test('passes the directory option to simple git', async () => { - // Given - mockedGitStatus.mockResolvedValue({isClean: () => true}) + test('passes the directory option', async () => { + mockGitCommand('') const directory = '/test/directory' - // When await git.isClean(directory) - // Then - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['status', '--porcelain'], {cwd: directory}) }) }) describe('addToGitIgnore()', () => { test('does nothing when .gitignore does not exist', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given const gitIgnorePath = `${tmpDir}/.gitignore` - // When git.addToGitIgnore(tmpDir, '.shopify') - // Then expect(fileExistsSync(gitIgnorePath)).toBe(false) }) }) test('does nothing when pattern already exists in .gitignore', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given const gitIgnorePath = `${tmpDir}/.gitignore` const gitIgnoreContent = ' .shopify \nnode_modules\n' writeFileSync(gitIgnorePath, gitIgnoreContent) - // When git.addToGitIgnore(tmpDir, '.shopify') - // Then const actualContent = readFileSync(gitIgnorePath).toString() expect(actualContent).toBe(gitIgnoreContent) }) @@ -535,15 +445,12 @@ describe('addToGitIgnore()', () => { test('appends pattern to .gitignore when file exists and pattern not present', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given const gitIgnorePath = `${tmpDir}/.gitignore` writeFileSync(gitIgnorePath, 'node_modules\ndist') - // When git.addToGitIgnore(tmpDir, '.shopify') - // Then const gitIgnoreContent = readFileSync(gitIgnorePath).toString() expect(gitIgnoreContent).toBe('node_modules\ndist\n.shopify\n') }) @@ -551,15 +458,12 @@ describe('addToGitIgnore()', () => { test('appends pattern to .gitignore when file exists and pattern not present without duplicating the last empty line', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given const gitIgnorePath = `${tmpDir}/.gitignore` writeFileSync(gitIgnorePath, 'node_modules\ndist\n') - // When git.addToGitIgnore(tmpDir, '.shopify') - // Then const gitIgnoreContent = readFileSync(gitIgnorePath).toString() expect(gitIgnoreContent).toBe('node_modules\ndist\n.shopify\n') }) @@ -567,16 +471,13 @@ describe('addToGitIgnore()', () => { test('does nothing when .shopify/* pattern exists in .gitignore', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given const gitIgnorePath = `${tmpDir}/.gitignore` const gitIgnoreContent = '.shopify/*\nnode_modules\n' writeFileSync(gitIgnorePath, gitIgnoreContent) - // When git.addToGitIgnore(tmpDir, '.shopify') - // Then const actualContent = readFileSync(gitIgnorePath).toString() expect(actualContent).toBe(gitIgnoreContent) }) @@ -584,16 +485,13 @@ describe('addToGitIgnore()', () => { test('does nothing when .shopify/** pattern exists in .gitignore', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given const gitIgnorePath = `${tmpDir}/.gitignore` const gitIgnoreContent = '.shopify/**\nnode_modules\n' writeFileSync(gitIgnorePath, gitIgnoreContent) - // When git.addToGitIgnore(tmpDir, '.shopify') - // Then const actualContent = readFileSync(gitIgnorePath).toString() expect(actualContent).toBe(gitIgnoreContent) }) @@ -601,42 +499,30 @@ describe('addToGitIgnore()', () => { }) describe('removeGitRemote()', () => { - beforeEach(() => { - mockedGetRemotes.mockReset() - mockedRemoveRemote.mockReset() - }) - - test('calls simple-git to remove a remote successfully', async () => { - // Given + test('calls git remote remove successfully', async () => { const directory = '/test/directory' const remoteName = 'origin' - mockedGetRemotes.mockResolvedValue([{name: 'origin', refs: {}}]) - mockedRemoveRemote.mockResolvedValue(undefined) + mockGitCommandSequence([ + // remote + {stdout: 'origin\n'}, + // remote remove + {stdout: ''}, + ]) - // When await git.removeGitRemote(directory, remoteName) - // Then - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) - expect(mockedGetRemotes).toHaveBeenCalled() - expect(mockedRemoveRemote).toHaveBeenCalledWith(remoteName) + expect(mockedExeca).toHaveBeenCalledWith('git', ['remote'], {cwd: directory}) + expect(mockedExeca).toHaveBeenCalledWith('git', ['remote', 'remove', remoteName], {cwd: directory}) }) test('does nothing when remote does not exist', async () => { - // Given const directory = '/test/directory' const remoteName = 'nonexistent' - mockedGetRemotes.mockResolvedValue([ - {name: 'origin', refs: {}}, - {name: 'upstream', refs: {}}, - ]) + mockGitCommand('origin\nupstream\n') - // When await git.removeGitRemote(directory, remoteName) - // Then - expect(simpleGit).toHaveBeenCalledWith({baseDir: directory}) - expect(mockedGetRemotes).toHaveBeenCalled() - expect(mockedRemoveRemote).not.toHaveBeenCalled() + expect(mockedExeca).toHaveBeenCalledWith('git', ['remote'], {cwd: directory}) + expect(mockedExeca).not.toHaveBeenCalledWith('git', ['remote', 'remove', remoteName], {cwd: directory}) }) }) diff --git a/packages/cli-kit/src/public/node/git.ts b/packages/cli-kit/src/public/node/git.ts index d79bee025b0..6d8bb4a1e36 100644 --- a/packages/cli-kit/src/public/node/git.ts +++ b/packages/cli-kit/src/public/node/git.ts @@ -13,10 +13,37 @@ import { import {AbortError} from './error.js' import {cwd, joinPath} from './path.js' import {runWithTimer} from './metadata.js' -import git, {TaskOptions, SimpleGitProgressEvent, DefaultLogFields, ListLogLine, SimpleGit} from 'simple-git' +import {execa} from 'execa' import ignore from 'ignore' +export interface GitLogEntry { + hash: string + date: string + message: string + refs: string + body: string + author_name: string + author_email: string +} + +async function gitCommand(args: string[], directory?: string): Promise { + try { + const result = await execa('git', args, {cwd: directory}) + return result.stdout + } catch (err) { + if (err instanceof Error) { + const abortError = new AbortError(err.message) + abortError.stack = err.stack + if ('exitCode' in err) { + Object.assign(abortError, {exitCode: err.exitCode}) + } + throw abortError + } + throw err + } +} + /** * Initialize a git repository at the given directory. * @@ -27,10 +54,8 @@ export async function initializeGitRepository(directory: string, initialBranch = outputDebug(outputContent`Initializing git repository at ${outputToken.path(directory)}...`) await ensureGitIsPresentOrAbort() // We use init and checkout instead of `init --initial-branch` because the latter is only supported in git 2.28+ - await withGit({directory}, async (repo) => { - await repo.init() - await repo.checkoutLocalBranch(initialBranch) - }) + await gitCommand(['init'], directory) + await gitCommand(['checkout', '-b', initialBranch], directory) } /** @@ -43,7 +68,14 @@ export async function initializeGitRepository(directory: string, initialBranch = * @returns Files ignored by the lockfile. */ export async function checkIfIgnoredInGitRepository(directory: string, files: string[]): Promise { - return withGit({directory}, (repo) => repo.checkIgnore(files)) + try { + const stdout = await gitCommand(['check-ignore', ...files], directory) + return stdout.split('\n').filter(Boolean) + } catch (error) { + // git check-ignore exits with code 1 when no files are ignored + if (error instanceof AbortError && 'exitCode' in error && error.exitCode === 1) return [] + throw error + } } export type GitIgnoreTemplate = Record @@ -109,14 +141,12 @@ export function addToGitIgnore(root: string, entry: string): void { * * @param repoUrl - The URL of the repository to clone. * @param destination - The directory where the repository will be cloned. - * @param progressUpdater - A function that will be called with the progress of the clone. * @param shallow - Whether to clone the repository shallowly. * @param latestTag - Whether to clone the latest tag instead of the default branch. */ export interface GitCloneOptions { repoUrl: string destination: string - progressUpdater?: (statusString: string) => void shallow?: boolean latestTag?: boolean } @@ -128,7 +158,7 @@ export interface GitCloneOptions { */ export async function downloadGitRepository(cloneOptions: GitCloneOptions): Promise { return runWithTimer('cmd_all_timing_network_ms')(async () => { - const {repoUrl, destination, progressUpdater, shallow, latestTag} = cloneOptions + const {repoUrl, destination, shallow, latestTag} = cloneOptions outputDebug(outputContent`Git-cloning repository ${repoUrl} into ${outputToken.path(destination)}...`) await ensureGitIsPresentOrAbort() @@ -158,46 +188,40 @@ export async function downloadGitRepository(cloneOptions: GitCloneOptions): Prom } const [repository, branch] = repoUrl.split('#') - const options: TaskOptions = {'--recurse-submodules': null} if (branch && latestTag) { throw new AbortError("Error cloning the repository. Git can't clone the latest release with a 'branch'.") } - if (branch) { - options['--branch'] = branch - } if (shallow && latestTag) { throw new AbortError( "Error cloning the repository. Git can't clone the latest release with the 'shallow' property.", ) } + + const args = ['clone', '--recurse-submodules'] + if (branch) { + args.push('--branch', branch) + } if (shallow) { - options['--depth'] = 1 + args.push('--depth', '1') } - - const progress = ({stage, progress, processed, total}: SimpleGitProgressEvent) => { - const updateString = `${stage}, ${processed}/${total} objects (${progress}% complete)` - if (progressUpdater) progressUpdater(updateString) + if (!isTerminalInteractive()) { + args.push('-c', 'core.askpass=true') } + args.push(repository!, destination) - const simpleGitOptions = { - progress, - ...(!isTerminalInteractive() && {config: ['core.askpass=true']}), - } try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - - await git(simpleGitOptions).clone(repository!, destination, options) + await execa('git', args) if (latestTag) { - await withGit({directory: destination}, async (localGitRepository) => { - const latestTag = await getLocalLatestTag(localGitRepository, repoUrl) - await localGitRepository.checkout(latestTag) - }) + const tag = await getLatestTagFromDirectory(destination, repoUrl) + await gitCommand(['checkout', tag], destination) } } catch (err) { + if (err instanceof AbortError) { + throw err + } if (err instanceof Error) { const abortError = new AbortError(err.message) abortError.stack = err.stack @@ -208,21 +232,15 @@ export async function downloadGitRepository(cloneOptions: GitCloneOptions): Prom }) } -/** - * Get the most recent tag of a local git repository. - * - * @param repository - The local git repository. - * @param repoUrl - The URL of the repository. - * @returns The most recent tag of the repository. - */ -async function getLocalLatestTag(repository: SimpleGit, repoUrl: string): Promise { - const latest = (await repository.tags()).latest +async function getLatestTagFromDirectory(directory: string, repoUrl: string): Promise { + const stdout = await gitCommand(['describe', '--tags', '--abbrev=0'], directory) + const tag = stdout.trim() - if (!latest) { + if (!tag) { throw new AbortError(`Couldn't obtain the most recent tag of the repository ${repoUrl}`) } - return latest + return tag } /** @@ -231,9 +249,10 @@ async function getLocalLatestTag(repository: SimpleGit, repoUrl: string): Promis * @param directory - The directory of the git repository. * @returns The latest commit of the repository. */ -export async function getLatestGitCommit(directory?: string): Promise { - const logs = await withGit({directory}, (repo) => repo.log({maxCount: 1})) - if (!logs.latest) { +export async function getLatestGitCommit(directory?: string): Promise { + const format = '%H%x00%ai%x00%s%x00%D%x00%b%x00%an%x00%ae' + const stdout = await gitCommand(['log', '-1', `--format=${format}`], directory) + if (!stdout.trim()) { throw new AbortError( 'Must have at least one commit to run command', outputContent`Run ${outputToken.genericShellCommand( @@ -241,7 +260,16 @@ export async function getLatestGitCommit(directory?: string): Promise { - await withGit({directory}, (repo) => repo.raw('add', '--all')) + await gitCommand(['add', '--all'], directory) } export interface CreateGitCommitOptions { @@ -267,9 +295,13 @@ export interface CreateGitCommitOptions { * @returns The hash of the created commit. */ export async function createGitCommit(message: string, options?: CreateGitCommitOptions): Promise { - const commitOptions = options?.author ? {'--author': options.author} : undefined - const result = await withGit({directory: options?.directory}, (repo) => repo.commit(message, commitOptions)) - return result.commit + const args = ['commit', '-m', message] + if (options?.author) { + args.push('--author', options.author) + } + await gitCommand(args, options?.directory) + const stdout = await gitCommand(['rev-parse', 'HEAD'], options?.directory) + return stdout.trim() } /** @@ -279,7 +311,7 @@ export async function createGitCommit(message: string, options?: CreateGitCommit * @returns The HEAD symbolic reference of the repository. */ export async function getHeadSymbolicRef(directory?: string): Promise { - const ref = await withGit({directory}, (repo) => repo.raw('symbolic-ref', '-q', 'HEAD')) + const ref = await gitCommand(['symbolic-ref', '-q', 'HEAD'], directory) if (!ref) { throw new AbortError( "Git HEAD can't be detached to run command", @@ -318,10 +350,8 @@ export class OutsideGitDirectoryError extends AbortError {} * @param directory - The directory to check. */ export async function ensureInsideGitDirectory(directory?: string): Promise { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore if (!(await insideGitDirectory(directory))) { - throw new OutsideGitDirectoryError(`${outputToken.path(directory || cwd())} is not a Git directory`) + throw new OutsideGitDirectoryError(`${outputToken.path(directory ?? cwd())} is not a Git directory`) } } @@ -332,7 +362,13 @@ export async function ensureInsideGitDirectory(directory?: string): Promise { - return withGit({directory}, (repo) => repo.checkIsRepo()) + try { + await execa('git', ['rev-parse', '--git-dir'], {cwd: directory}) + return true + } catch (error) { + if (error instanceof Error && 'exitCode' in error && error.exitCode === 128) return false + throw error + } } export class GitDirectoryNotCleanError extends AbortError {} @@ -344,7 +380,7 @@ export class GitDirectoryNotCleanError extends AbortError {} */ export async function ensureIsClean(directory?: string): Promise { if (!(await isClean(directory))) { - throw new GitDirectoryNotCleanError(`${outputToken.path(directory || cwd())} is not a clean Git directory`) + throw new GitDirectoryNotCleanError(`${outputToken.path(directory ?? cwd())} is not a clean Git directory`) } } @@ -355,7 +391,8 @@ export async function ensureIsClean(directory?: string): Promise { * @returns True is the .git directory is clean. */ export async function isClean(directory?: string): Promise { - return (await withGit({directory}, (git: SimpleGit) => git.status())).isClean() + const stdout = await gitCommand(['status', '--porcelain'], directory) + return stdout.trim() === '' } /** @@ -365,8 +402,13 @@ export async function isClean(directory?: string): Promise { * @returns String with the latest tag or undefined if no tags are found. */ export async function getLatestTag(directory?: string): Promise { - const tags = await withGit({directory}, (repo) => repo.tags()) - return tags.latest + try { + const stdout = await gitCommand(['describe', '--tags', '--abbrev=0'], directory) + return stdout.trim() || undefined + } catch (error) { + if (error instanceof AbortError && 'exitCode' in error && error.exitCode === 128) return undefined + throw error + } } /** @@ -380,39 +422,13 @@ export async function removeGitRemote(directory: string, remoteName = 'origin'): outputDebug(outputContent`Removing git remote ${remoteName} from ${outputToken.path(directory)}...`) await ensureGitIsPresentOrAbort() - await withGit({directory}, async (repo) => { - // Check if remote exists first - const remotes = await repo.getRemotes() - const remoteExists = remotes.some((remote: {name: string}) => remote.name === remoteName) + const stdout = await gitCommand(['remote'], directory) + const remotes = stdout.split('\n').filter(Boolean) - if (!remoteExists) { - outputDebug(outputContent`Remote ${remoteName} does not exist, no action needed`) - return - } - - await repo.removeRemote(remoteName) - }) -} - -async function withGit( - { - directory, - }: { - directory?: string - }, - callback: (git: SimpleGit) => Promise, -): Promise { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const repo = git({baseDir: directory}) - try { - return await callback(repo) - } catch (err) { - if (err instanceof Error) { - const abortError = new AbortError(err.message) - abortError.stack = err.stack - throw abortError - } - throw err + if (!remotes.includes(remoteName)) { + outputDebug(outputContent`Remote ${remoteName} does not exist, no action needed`) + return } + + await gitCommand(['remote', 'remove', remoteName], directory) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b4f7d67149..37acb736415 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,9 +486,6 @@ importers: semver: specifier: 7.6.3 version: 7.6.3 - simple-git: - specifier: 3.32.3 - version: 3.32.3 stacktracey: specifier: 2.1.8 version: 2.1.8