UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

321 lines • 13.2 kB
/* eslint-disable @typescript-eslint/no-base-to-string */ import { hasGit, isTerminalInteractive } from './context/local.js'; import { appendFileSync, detectEOL, fileExists, fileExistsSync, glob, isDirectory, readFileSync, writeFileSync, } from './fs.js'; import { AbortError } from './error.js'; import { cwd, joinPath } from './path.js'; import { runWithTimer } from './metadata.js'; import { outputContent, outputToken, outputDebug } from '../../public/node/output.js'; import git from 'simple-git'; import ignore from 'ignore'; /** * Initialize a git repository at the given directory. * * @param directory - The directory where the git repository will be initialized. * @param initialBranch - The name of the initial branch. */ export async function initializeGitRepository(directory, initialBranch = 'main') { 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); }); } /** * Given a Git repository and a list of absolute paths to files contained * in the repository, it filters and returns the files that are ignored * by the .gitignore. * * @param directory - The absolute path to the directory containing the files. * @param files - The list of files to check against. * @returns Files ignored by the lockfile. */ export async function checkIfIgnoredInGitRepository(directory, files) { return withGit({ directory }, (repo) => repo.checkIgnore(files)); } /** * Create a .gitignore file in the given directory. * * @param directory - The directory where the .gitignore file will be created. * @param template - The template to use to create the .gitignore file. */ export function createGitIgnore(directory, template) { outputDebug(outputContent `Creating .gitignore at ${outputToken.path(directory)}...`); const filePath = `${directory}/.gitignore`; let fileContent = ''; for (const [section, lines] of Object.entries(template)) { fileContent += `# ${section}\n`; fileContent += `${lines.join('\n')}\n\n`; } appendFileSync(filePath, fileContent); } /** * Add an entry to an existing .gitignore file. * * If the .gitignore file doesn't exist, or if the entry is already present, * no changes will be made. * * @param root - The directory containing the .gitignore file. * @param entry - The entry to add to the .gitignore file. */ export function addToGitIgnore(root, entry) { const gitIgnorePath = joinPath(root, '.gitignore'); if (!fileExistsSync(gitIgnorePath)) { // When the .gitignore file does not exist, the CLI should not be opinionated about creating it return; } const gitIgnoreContent = readFileSync(gitIgnorePath).toString(); const eol = detectEOL(gitIgnoreContent); const lines = gitIgnoreContent.split(eol).map((line) => line.trim()); const ignoreManager = ignore.default({ allowRelativePaths: true }).add(lines); const isIgnoredEntry = ignoreManager.ignores(joinPath(entry)); const isIgnoredEntryAsDir = ignoreManager.ignores(joinPath(entry, 'ignored.txt')); const isAlreadyIgnored = isIgnoredEntry || isIgnoredEntryAsDir; if (isAlreadyIgnored) { // The file is already ignored by an existing pattern return; } if (gitIgnoreContent.endsWith(eol)) { writeFileSync(gitIgnorePath, `${gitIgnoreContent}${entry}${eol}`); } else { writeFileSync(gitIgnorePath, `${gitIgnoreContent}${eol}${entry}${eol}`); } } /** * Clone a git repository. * * @param cloneOptions - The options to use to clone the repository. * @returns A promise that resolves when the clone is complete. */ export async function downloadGitRepository(cloneOptions) { return runWithTimer('cmd_all_timing_network_ms')(async () => { const { repoUrl, destination, progressUpdater, shallow, latestTag } = cloneOptions; outputDebug(outputContent `Git-cloning repository ${repoUrl} into ${outputToken.path(destination)}...`); await ensureGitIsPresentOrAbort(); // Validate destination directory before attempting to clone if (await fileExists(destination)) { // Check if it's a directory if (!(await isDirectory(destination))) { throw new AbortError(outputContent `Can't clone to ${outputToken.path(destination)}`, "The path exists but isn't a directory."); } // Check if directory is empty const entries = await glob(['*', '.*'], { cwd: destination, deep: 1, onlyFiles: false, }); if (entries.length > 0) { throw new AbortError(outputContent `Directory ${outputToken.path(destination)} already exists and is not empty`, outputContent `Choose a different name or remove the existing directory first.`); } } const [repository, branch] = repoUrl.split('#'); const options = { '--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."); } if (shallow) { options['--depth'] = 1; } const progress = ({ stage, progress, processed, total }) => { const updateString = `${stage}, ${processed}/${total} objects (${progress}% complete)`; if (progressUpdater) progressUpdater(updateString); }; const simpleGitOptions = { progress, ...(!isTerminalInteractive() && { config: ['core.askpass=true'] }), }; try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await git(simpleGitOptions).clone(repository, destination, options); if (latestTag) { await withGit({ directory: destination }, async (localGitRepository) => { const latestTag = await getLocalLatestTag(localGitRepository, repoUrl); await localGitRepository.checkout(latestTag); }); } } catch (err) { if (err instanceof Error) { const abortError = new AbortError(err.message); abortError.stack = err.stack; throw abortError; } throw err; } }); } /** * 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, repoUrl) { const latest = (await repository.tags()).latest; if (!latest) { throw new AbortError(`Couldn't obtain the most recent tag of the repository ${repoUrl}`); } return latest; } /** * Get the latest commit of a git repository. * * @param directory - The directory of the git repository. * @returns The latest commit of the repository. */ export async function getLatestGitCommit(directory) { const logs = await withGit({ directory }, (repo) => repo.log({ maxCount: 1 })); if (!logs.latest) { throw new AbortError('Must have at least one commit to run command', outputContent `Run ${outputToken.genericShellCommand("git commit -m 'Initial commit'")} to create your first commit.`); } return logs.latest; } /** * Add all files to the git index from the given directory. * * @param directory - The directory where the git repository is located. * @returns A promise that resolves when the files are added to the index. */ export async function addAllToGitFromDirectory(directory) { await withGit({ directory }, (repo) => repo.raw('add', '--all')); } /** * Create a git commit. * * @param message - The message of the commit. * @param options - The options to use to create the commit. * @returns The hash of the created commit. */ export async function createGitCommit(message, options) { const commitOptions = options?.author ? { '--author': options.author } : undefined; const result = await withGit({ directory: options?.directory }, (repo) => repo.commit(message, commitOptions)); return result.commit; } /** * Get the HEAD symbolic reference of a git repository. * * @param directory - The directory of the git repository. * @returns The HEAD symbolic reference of the repository. */ export async function getHeadSymbolicRef(directory) { const ref = await withGit({ directory }, (repo) => repo.raw('symbolic-ref', '-q', 'HEAD')); if (!ref) { throw new AbortError("Git HEAD can't be detached to run command", outputContent `Run ${outputToken.genericShellCommand('git checkout [branchName]')} to reattach HEAD or see git ${outputToken.link('documentation', 'https://git-scm.com/book/en/v2/Git-Internals-Git-References')} for more details`); } return ref.trim(); } /** * If "git" is not present in the environment it throws * an abort error. */ export async function ensureGitIsPresentOrAbort() { if (!(await hasGit())) { throw new AbortError(`Git is necessary in the environment to continue`, outputContent `Install ${outputToken.link('git', 'https://git-scm.com/book/en/v2/Getting-Started-Installing-Git')}`); } } export class OutsideGitDirectoryError extends AbortError { } /** * If command run from outside a .git directory tree * it throws an abort error. * * @param directory - The directory to check. */ export async function ensureInsideGitDirectory(directory) { // 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`); } } /** * Returns true if the given directory is inside a .git directory tree. * * @param directory - The directory to check. * @returns True if the directory is inside a .git directory tree. */ export async function insideGitDirectory(directory) { return withGit({ directory }, (repo) => repo.checkIsRepo()); } export class GitDirectoryNotCleanError extends AbortError { } /** * If the .git directory tree is not clean (has uncommitted changes) * it throws an abort error. * * @param directory - The directory to check. */ export async function ensureIsClean(directory) { if (!(await isClean(directory))) { throw new GitDirectoryNotCleanError(`${outputToken.path(directory || cwd())} is not a clean Git directory`); } } /** * Returns true if the .git directory tree is clean (no uncommitted changes). * * @param directory - The directory to check. * @returns True is the .git directory is clean. */ export async function isClean(directory) { return (await withGit({ directory }, (git) => git.status())).isClean(); } /** * Returns the latest tag of a git repository. * * @param directory - The directory to check. * @returns String with the latest tag or undefined if no tags are found. */ export async function getLatestTag(directory) { const tags = await withGit({ directory }, (repo) => repo.tags()); return tags.latest; } /** * Remove a git remote from the given directory. * * @param directory - The directory where the git repository is located. * @param remoteName - The name of the remote to remove (defaults to 'origin'). * @returns A promise that resolves when the remote is removed. */ export async function removeGitRemote(directory, 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) => remote.name === remoteName); if (!remoteExists) { outputDebug(outputContent `Remote ${remoteName} does not exist, no action needed`); return; } await repo.removeRemote(remoteName); }); } async function withGit({ directory, }, callback) { // 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; } } //# sourceMappingURL=git.js.map