@shopify/cli-kit
Version:
A set of utilities, interfaces, and models that are common across all the platform features
321 lines • 13.2 kB
JavaScript
/* 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