import _ from 'lodash'; import os from 'os'; import path from 'path'; import { v4 as uuid } from 'uuid'; import fse from 'fs-extra'; import { GitServiceInterface, GitServicePullListener, GitServicePushListener, GitFileCommitDescriptor, GitAuthor, GitCommitLogEntry, Logger, GitFileStatus } from '@stackbit/types'; import { isTruthy, Worker } from '@stackbit/utils'; import { CommandRunner } from '@stackbit/types'; const GIT_LOG_CHANGE_TYPES: Record = { M: 'modified', A: 'added', D: 'deleted' }; export class GitService implements GitServiceInterface { private repoUrl: string; private repoBranch: string; private repoPublishBranch: string; private readonly repoDir: string; private readonly worker: Worker; private readonly runCommand: CommandRunner; private readonly logger: Logger; private readonly userLogger: Logger; private readonly skipPush?: boolean; private pullListeners: GitServicePullListener[]; private pushListeners: GitServicePushListener[]; private branchFetched: boolean = false; private shadowRepoDir: string | undefined; private pulledFilesFromFetchHead: GitFileStatus[] | undefined; constructor(options: { repoUrl: string; repoDir: string; repoBranch: string; repoPublishBranch: string; worker: Worker; runCommand: CommandRunner; skipPush?: boolean; logger: Logger; userLogger: Logger; }) { this.repoUrl = options.repoUrl; this.repoDir = options.repoDir; this.repoBranch = options.repoBranch; this.repoPublishBranch = options.repoPublishBranch; this.worker = options.worker; this.runCommand = options.runCommand; this.skipPush = options.skipPush; this.logger = options.logger.createLogger({ label: 'git' }); this.userLogger = options.userLogger.createLogger({ label: 'git' }); this.pullListeners = []; this.pushListeners = []; } getRepoUrl() { return this.repoUrl; } setRepoUrl(repoUrl: string) { this.repoUrl = repoUrl; } getRepoBranch() { return this.repoBranch; } setRepoBranch(repoBranch: string) { this.repoBranch = repoBranch; } getRepoPublishBranch() { return this.repoPublishBranch; } setRepoPublishBranch(publishBranch: string) { this.repoPublishBranch = publishBranch; } getRepoDir() { return this.repoDir; } addPullListener(listener: GitServicePullListener) { this.pullListeners.push(listener); } removePullListener(listener: GitServicePullListener) { this.pullListeners = this.pullListeners.filter((existingListener) => existingListener != listener); } async notifyPullListeners(options: { branch: string; updatedFiles: string[] }) { for (const listener of this.pullListeners) { try { await listener(options); } catch (err) { this.logger.error('Error invoking pull listener', { ...options, err }); } } } addPushListener(listener: GitServicePushListener) { this.pushListeners.push(listener); } removePushListener(listener: GitServicePushListener) { this.pushListeners = this.pushListeners.filter((existingListener) => existingListener != listener); } async notifyPushListeners() { for (const listener of this.pushListeners) { try { await listener(); } catch (err) { this.logger.error('Error invoking push listener', { err }); } } } private async commit(author: GitAuthor, files: GitFileCommitDescriptor[]) { const filePaths = _.map(files, 'filePath'); this.logger.debug('Commit scheduled', filePaths); const cwd = this.shadowRepoDir ?? this.repoDir; return this.worker.schedule(async () => { this.logger.debug('Commit running', filePaths); const message = files .reduce((messages: string[], file) => { messages.push(`${path.parse(file.filePath).base}: ${file.description}`); return messages; }, []) .join('.\n'); if (this.shadowRepoDir) { await Promise.all( files.map((file) => this.runCommand('cp', [file.filePath, path.join(this.shadowRepoDir!, file.filePath)], { cwd: this.repoDir })) ); } await this.runCommand('git', ['add', ...filePaths], { cwd }); await this.runCommand('git', ['commit', '--no-verify', '--author', `${author.name || author.email} <${author.email}>`, '-m', message], { cwd }).catch((err) => { this.logger.debug(`Could not commit`, err); }); this.logger.debug('Commit done', filePaths); }); } private async push() { if (this.skipPush) { this.logger.debug('Push skipped...'); return Promise.resolve(); } this.logger.debug('Push scheduled'); const cwd = this.shadowRepoDir ?? this.repoDir; await this.worker.schedule(async () => { this.logger.debug('Push running'); await this.runCommand('rm', ['-rf', '.git/rebase-merge'], { cwd }).catch((err) => { // fixes leftover rebase directory with autostash this.logger.debug(`Could not remove rebase-merge`, err); }); await this.runCommand('git', ['pull', 'origin', this.repoBranch, '--rebase', '--autostash', '-Xtheirs'], { cwd }); await this.runCommand('git', ['push', '--no-verify', 'origin', this.repoBranch], { cwd }); this.logger.debug('Push done'); }); return this.notifyPushListeners(); } async commitAndPush(author: GitAuthor, files: GitFileCommitDescriptor[]): Promise { await this.commit(author, files); return this.push(); } async pull(branch?: string): Promise { let updatedFiles: string[] = []; const pullBranch = branch ?? this.repoBranch; this.logger.debug('Pull scheduled', { pullBranch }); await this.worker.schedule(async () => { this.logger.debug('Pull running', { pullBranch }); await this.runCommand('git', ['fetch', '--no-write-fetch-head', 'origin'], { cwd: this.repoDir }).catch((err) => this.logger.error('Error fetching before pull', { err }) ); updatedFiles = await this.diffBranches(pullBranch, pullBranch); if (pullBranch === this.repoBranch) { await this.runCommand('git', ['pull', 'origin', '--rebase', '--autostash', '-Xtheirs'], { cwd: this.repoDir }); } this.logger.debug('Pull done', { pullBranch }); }); await this.notifyPullListeners({ branch: pullBranch, updatedFiles }); } private async publishAll(author: GitAuthor) { this.logger.debug('Publish all started'); const publishDir = path.join(os.tmpdir(), uuid()); await this.runCommand('git', ['clone', this.repoUrl, '--branch', this.repoPublishBranch, publishDir]); try { await this.runCommand('git', ['merge', `origin/${this.repoBranch}`, this.repoPublishBranch, '-Xtheirs'], { cwd: publishDir }); await this.runCommand('git', ['commit', '--author', `${author.name || author.email} <${author.email}>`, `-m`, 'Publish'], { cwd: publishDir }).catch((err) => { this.logger.debug(`Could not do the publish commit`, err); }); await this.runCommand('git', ['push', 'origin'], { cwd: publishDir }); } finally { await fse.remove(publishDir); } this.logger.debug('Publish all done'); } private async publishFiles(author: GitAuthor, filePaths: string[]) { this.logger.debug('Publish files started', filePaths); const publishDir = path.join(os.tmpdir(), uuid()); await this.runCommand('git', ['clone', this.repoUrl, '--branch', this.repoPublishBranch, publishDir]); try { await this.runCommand('git', ['checkout', '-b', 'stackbit-publish-branch'], { cwd: publishDir }); const filePathsToAdd: string[] = []; for (const filePath of filePaths) { const srcFilePath = path.join(this.repoDir, filePath); const destFilePath = path.join(publishDir, filePath); if (await fse.pathExists(srcFilePath)) { await fse.ensureDir(path.dirname(destFilePath)); await fse.copy(srcFilePath, destFilePath); filePathsToAdd.push(filePath); } else if (await fse.pathExists(destFilePath)) { // remove file if it was deleted from preview branch but exists in publish branch await this.runCommand('git', ['rm', filePath], { cwd: publishDir }); } } await this.runCommand('git', ['add', '--ignore-errors', ...filePathsToAdd], { cwd: publishDir }); await this.runCommand('git', ['commit', '--author', `${author.name || author.email} <${author.email}>`, '-m', 'Publish'], { cwd: publishDir }).catch((err: any) => { this.logger.debug(`Could not do the publish commit`, err); }); await this.runCommand('git', ['checkout', this.repoBranch], { cwd: publishDir }); await this.runCommand('git', ['merge', 'stackbit-publish-branch', this.repoBranch, '-Xtheirs'], { cwd: publishDir }); await this.runCommand('git', ['checkout', this.repoPublishBranch], { cwd: publishDir }); await this.runCommand('git', ['merge', 'stackbit-publish-branch', this.repoPublishBranch, '-Xtheirs'], { cwd: publishDir }); await this.runCommand('git', ['push', 'origin', this.repoPublishBranch, this.repoBranch], { cwd: publishDir }); } finally { await fse.remove(publishDir); } this.logger.debug('Publish files done', filePaths); } publish(author: GitAuthor, filePaths?: string[]): Promise { this.logger.debug('Publish scheduled'); return this.worker.schedule(async () => { if (filePaths) { if (!filePaths.length) { this.logger.debug('Nothing to publish'); return; } return this.publishFiles(author, filePaths); } else { return this.publishAll(author); } }); } private parseGitCommitAuthor(author?: string) { if (!author) { return author; } const regex = /(.*)\((.*)\)/; const match = author.match(regex); if (match) { const [authorEmail, authorName] = match.slice(1); if (authorName === 'Stackbit Code Editor') { return 'stackbit'; } return authorEmail ? authorEmail.toLowerCase() : author; } return author; } private async diffBranches(fromBranch: string, toBranch: string): Promise { const result = await this.runCommand( 'git', [ 'diff', '--name-only', '--no-renames', // this flag makes sure we get both old and new name of renamed file `origin/${toBranch}..${fromBranch}` ], { cwd: this.repoDir } ); return result.stdout.split('\n').filter(Boolean); } async diff(): Promise { this.logger.debug('Diff check scheduled'); return this.worker.schedule(async () => { this.logger.debug('Diff check running'); const result = await this.diffBranches(this.repoBranch, this.repoPublishBranch); this.logger.debug('Diff check done'); return result; }); } async diffFilesWithFetchHead(): Promise { await this.runCommand('git', ['fetch'], { cwd: this.repoDir }); const result = await this.runCommand( 'git', [ 'diff', '--name-status', '--no-renames', // this flag makes sure we get both old and new name of renamed file 'HEAD...FETCH_HEAD' ], { cwd: this.repoDir } ); return result.stdout .split('\n') .filter(Boolean) .map((str) => { const [status, filePath] = str.split('\t'); if (!filePath || !status) { return null; } return { filePath, status: GIT_LOG_CHANGE_TYPES[status] || 'modified' }; }) .filter(isTruthy); } private async rollbackFilesFromHead(files: GitFileStatus[]) { if (!files.length) { return; } const filesForDeletion: string[] = []; const filesForCheckout: string[] = []; files.forEach((file) => { if (file.status === 'added') { filesForDeletion.push(file.filePath); } else { filesForCheckout.push(file.filePath); } }); if (filesForDeletion.length) { await this.runCommand('rm', filesForDeletion, { cwd: this.repoDir }); } if (filesForCheckout.length) { await this.runCommand('git', ['checkout', 'HEAD', '--', ...filesForCheckout], { cwd: this.repoDir }); } } async pullFilesFromFetchHead(files: GitFileStatus[]) { if (this.pulledFilesFromFetchHead) { await this.rollbackFilesFromHead(this.pulledFilesFromFetchHead); this.pulledFilesFromFetchHead = undefined; } if (!files.length) { return; } const filesForDeletion: string[] = []; const filesForCheckout: string[] = []; files.forEach((file) => { if (file.status === 'deleted') { filesForDeletion.push(file.filePath); } else { filesForCheckout.push(file.filePath); } }); if (filesForDeletion.length) { await this.runCommand('rm', filesForDeletion, { cwd: this.repoDir }); } if (filesForCheckout.length) { await this.runCommand('git', ['checkout', 'FETCH_HEAD', '--', ...filesForCheckout], { cwd: this.repoDir }); } this.pulledFilesFromFetchHead = files; } async createShadowRepo() { if (this.shadowRepoDir) { return; } this.shadowRepoDir = path.join(os.tmpdir(), uuid()); await this.runCommand('git', ['clone', this.repoUrl, '--branch', this.repoBranch, this.shadowRepoDir], { cwd: this.repoDir }); this.logger.debug('Created shadow repo for committing changes'); } /** * Parse commit log between two branches * command: git log --pretty=format:commit:%H%n%at%n%ae%x28%an%x29 --name-status master..preview * * Every commit entry has the following pattern * ``` * commit:{COMMIT_HASH} * {UNIX_TIMESTAMP} * {AUTHOR_EMAIL}({AUTHOR_NAME}) * {OPTIONAL_CHANGES} * {OPTIONAL_CHANGES} * ``` * * The OPTIONAL_CHANGES can be one of the two following formats: * ``` * CODE PATH * CODE ORIG_PATH -> PATH * ``` * * The CODE can be one of the following: * M - modified * T - file type changed (regular file, symbolic link or submodule) * A = added * D = deleted * R = renamed * C = copied (if config option status.renames is set to "copies") * U = updated but unmerged * * The ORIG_PATH is where the renamed/copied contents came from. * The ORIG_PATH is only shown when the entry is renamed or copied. * * @example * commit:eeb5da951efd67177450340ed1d9f097de1880d1 * 1725289941 * john@doe.com(John Doe) * R100 content/pages/bar.md content/pages/bar2.md * * commit:c8e3582f5f5da508d288a704274a79c3c5910c2e * 1725289821 * john@doe.com(John Doe) * commit:99ccff89435652b049ff257a97f24ef7e138ecd2 * 1725289805 * john@doe.com(John Doe) * A content/pages/bar.md * * commit:1db31aba03eac648c93b5f18d87d555bf77380be * 1725288846 * john@doe.com(John Doe) * R071 content/pages/foo.md content/pages/foo2.md * * commit:eb392a5d1eacff5620bed8e558733bd6f2c4fc51 * 1725288806 * john@doe.com(John Doe) * commit:9b5dad9e257a506ef3a1da7cbec0d50f0e2a47db * 1725288647 * john@doe.com(John Doe) * A content/pages/foo.md * D content/pages/hello11.md * * commit:0adb4e9b3de382f80720d5acc1027f7efb240f14 * 1725229056 * john@doe.com(John Doe) * commit:81189019c5f6de62525a755333434a40c4ab4d44 * 1725228656 * john@doe.com(John Doe) * R100 content/pages/hello10.md content/pages/hello11.md */ async commitLog(): Promise { this.logger.debug('commitLog scheduled'); return this.worker.schedule(async () => { this.logger.debug('commitLog running'); if (!this.branchFetched && this.repoPublishBranch !== this.repoBranch) { await this.runCommand('git', ['fetch', '--no-write-fetch-head', 'origin', `${this.repoPublishBranch}:${this.repoPublishBranch}`], { cwd: this.repoDir }); this.branchFetched = true; } const logResult = await this.runCommand( 'git', ['log', '--pretty=format:commit:%H%n%at%n%ae%x28%an%x29', '--name-status', `${this.repoPublishBranch}..${this.repoBranch}`], { cwd: this.repoDir } ); this.logger.debug('commitLog finished'); return logResult.stdout .split('commit:') .filter(Boolean) .map((rawCommit) => { const split = rawCommit.trim().split('\n'); return { author: this.parseGitCommitAuthor(split[2]), timestamp: split[1] ? new Date(parseInt(split[1]) * 1000) : undefined, commitHash: split[0], changes: split .slice(3) .map((line) => line.trim().split(/\t/)) .filter(Boolean) .filter(([status, filename, _]) => status && filename) .map(([status, filename, auxFilename]) => { if (status?.startsWith('R')) { return { status: 'modified', filePath: auxFilename, fromFilePath: filename }; } else if (status?.startsWith('C')) { return { status: 'added', filePath: auxFilename }; } const gitStatus = GIT_LOG_CHANGE_TYPES[status!] || 'modified'; return { status: gitStatus, filePath: filename }; }) }; }) .reverse(); }); } }