| import { Logger } from '@n8n/backend-common'; |
| import type { User } from '@n8n/db'; |
| import { Service } from '@n8n/di'; |
| import { execSync } from 'child_process'; |
| import { UnexpectedError } from 'n8n-workflow'; |
| import path from 'path'; |
| import type { |
| CommitResult, |
| DiffResult, |
| FetchResult, |
| PullResult, |
| PushResult, |
| SimpleGit, |
| SimpleGitOptions, |
| StatusResult, |
| } from 'simple-git'; |
|
|
| import { OwnershipService } from '@/services/ownership.service'; |
|
|
| import { |
| SOURCE_CONTROL_DEFAULT_BRANCH, |
| SOURCE_CONTROL_DEFAULT_EMAIL, |
| SOURCE_CONTROL_DEFAULT_NAME, |
| SOURCE_CONTROL_ORIGIN, |
| } from './constants'; |
| import { sourceControlFoldersExistCheck } from './source-control-helper.ee'; |
| import { SourceControlPreferencesService } from './source-control-preferences.service.ee'; |
| import type { SourceControlPreferences } from './types/source-control-preferences'; |
|
|
| @Service() |
| export class SourceControlGitService { |
| git: SimpleGit | null = null; |
|
|
| private gitOptions: Partial<SimpleGitOptions> = {}; |
|
|
| constructor( |
| private readonly logger: Logger, |
| private readonly ownershipService: OwnershipService, |
| private readonly sourceControlPreferencesService: SourceControlPreferencesService, |
| ) {} |
|
|
| |
| |
| |
| |
| private preInitCheck(): boolean { |
| this.logger.debug('GitService.preCheck'); |
| try { |
| const gitResult = execSync('git --version', { |
| stdio: ['pipe', 'pipe', 'pipe'], |
| }); |
| this.logger.debug(`Git binary found: ${gitResult.toString()}`); |
| } catch (error) { |
| throw new UnexpectedError('Git binary not found', { cause: error }); |
| } |
| try { |
| const sshResult = execSync('ssh -V', { |
| stdio: ['pipe', 'pipe', 'pipe'], |
| }); |
| this.logger.debug(`SSH binary found: ${sshResult.toString()}`); |
| } catch (error) { |
| throw new UnexpectedError('SSH binary not found', { cause: error }); |
| } |
| return true; |
| } |
|
|
| async initService(options: { |
| sourceControlPreferences: SourceControlPreferences; |
| gitFolder: string; |
| sshFolder: string; |
| sshKeyName: string; |
| }): Promise<void> { |
| const { sourceControlPreferences: sourceControlPreferences, gitFolder, sshFolder } = options; |
| this.logger.debug('GitService.init'); |
| if (this.git !== null) { |
| return; |
| } |
|
|
| this.preInitCheck(); |
| this.logger.debug('Git pre-check passed'); |
|
|
| sourceControlFoldersExistCheck([gitFolder, sshFolder]); |
|
|
| await this.setGitSshCommand(gitFolder, sshFolder); |
|
|
| if (!(await this.checkRepositorySetup())) { |
| await (this.git as unknown as SimpleGit).init(); |
| } |
| if (!(await this.hasRemote(sourceControlPreferences.repositoryUrl))) { |
| if (sourceControlPreferences.connected && sourceControlPreferences.repositoryUrl) { |
| const instanceOwner = await this.ownershipService.getInstanceOwner(); |
| await this.initRepository(sourceControlPreferences, instanceOwner); |
| } |
| } |
| } |
|
|
| |
| |
| |
| async setGitSshCommand( |
| gitFolder = this.sourceControlPreferencesService.gitFolder, |
| sshFolder = this.sourceControlPreferencesService.sshFolder, |
| ) { |
| const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath(); |
|
|
| const sshKnownHosts = path.join(sshFolder, 'known_hosts'); |
| const sshCommand = `ssh -o UserKnownHostsFile=${sshKnownHosts} -o StrictHostKeyChecking=no -i ${privateKeyPath}`; |
|
|
| this.gitOptions = { |
| baseDir: gitFolder, |
| binary: 'git', |
| maxConcurrentProcesses: 6, |
| trimmed: false, |
| }; |
|
|
| const { simpleGit } = await import('simple-git'); |
|
|
| this.git = simpleGit(this.gitOptions) |
| .env('GIT_SSH_COMMAND', sshCommand) |
| .env('GIT_TERMINAL_PROMPT', '0'); |
| } |
|
|
| resetService() { |
| this.git = null; |
| } |
|
|
| private async checkRepositorySetup(): Promise<boolean> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (async)'); |
| } |
| if (!(await this.git.checkIsRepo())) { |
| return false; |
| } |
| try { |
| await this.git.status(); |
| return true; |
| } catch (error) { |
| return false; |
| } |
| } |
|
|
| private async hasRemote(remote: string): Promise<boolean> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (async)'); |
| } |
| try { |
| const remotes = await this.git.getRemotes(true); |
| const foundRemote = remotes.find( |
| (e) => e.name === SOURCE_CONTROL_ORIGIN && e.refs.push === remote, |
| ); |
| if (foundRemote) { |
| this.logger.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`); |
| return true; |
| } |
| } catch (error) { |
| throw new UnexpectedError('Git is not initialized', { cause: error }); |
| } |
| this.logger.debug(`Git remote not found: ${remote}`); |
| return false; |
| } |
|
|
| async initRepository( |
| sourceControlPreferences: Pick< |
| SourceControlPreferences, |
| 'repositoryUrl' | 'branchName' | 'initRepo' |
| >, |
| user: User, |
| ): Promise<void> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (Promise)'); |
| } |
| if (sourceControlPreferences.initRepo) { |
| try { |
| await this.git.init(); |
| } catch (error) { |
| this.logger.debug(`Git init: ${(error as Error).message}`); |
| } |
| } |
| try { |
| await this.git.addRemote(SOURCE_CONTROL_ORIGIN, sourceControlPreferences.repositoryUrl); |
| this.logger.debug(`Git remote added: ${sourceControlPreferences.repositoryUrl}`); |
| } catch (error) { |
| if ((error as Error).message.includes('remote origin already exists')) { |
| this.logger.debug(`Git remote already exists: ${(error as Error).message}`); |
| } else { |
| throw error; |
| } |
| } |
| await this.setGitUserDetails( |
| user.firstName && user.lastName |
| ? `${user.firstName} ${user.lastName}` |
| : SOURCE_CONTROL_DEFAULT_NAME, |
| user.email ?? SOURCE_CONTROL_DEFAULT_EMAIL, |
| ); |
|
|
| await this.trackRemoteIfReady(sourceControlPreferences.branchName); |
|
|
| if (sourceControlPreferences.initRepo) { |
| try { |
| const branches = await this.getBranches(); |
| if (branches.branches?.length === 0) { |
| await this.git.raw(['branch', '-M', sourceControlPreferences.branchName]); |
| } |
| } catch (error) { |
| this.logger.debug(`Git init: ${(error as Error).message}`); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| private async trackRemoteIfReady(targetBranch: string) { |
| if (!this.git) return; |
|
|
| await this.fetch(); |
|
|
| const { currentBranch, branches: remoteBranches } = await this.getBranches(); |
|
|
| if (!currentBranch && remoteBranches.some((b) => b === targetBranch)) { |
| await this.git.checkout(targetBranch); |
|
|
| const upstream = [SOURCE_CONTROL_ORIGIN, targetBranch].join('/'); |
|
|
| await this.git.branch([`--set-upstream-to=${upstream}`, targetBranch]); |
|
|
| this.logger.info('Set local git repository to track remote', { upstream }); |
| } |
| } |
|
|
| async setGitUserDetails(name: string, email: string): Promise<void> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (setGitUserDetails)'); |
| } |
| await this.git.addConfig('user.email', email); |
| await this.git.addConfig('user.name', name); |
| } |
|
|
| async getBranches(): Promise<{ branches: string[]; currentBranch: string }> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (getBranches)'); |
| } |
|
|
| try { |
| |
| const { branches } = await this.git.branch(['-r']); |
| const remoteBranches = Object.keys(branches) |
| .map((name) => name.split('/').slice(1).join('/')) |
| .filter((name) => name !== 'HEAD'); |
|
|
| const { current } = await this.git.branch(); |
|
|
| return { |
| branches: remoteBranches, |
| currentBranch: current, |
| }; |
| } catch (error) { |
| throw new UnexpectedError('Could not get remote branches from repository', { cause: error }); |
| } |
| } |
|
|
| async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (setBranch)'); |
| } |
| await this.git.checkout(branch); |
| await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]); |
| return await this.getBranches(); |
| } |
|
|
| async getCurrentBranch(): Promise<{ current: string; remote: string }> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (getCurrentBranch)'); |
| } |
| const currentBranch = (await this.git.branch()).current; |
| return { |
| current: currentBranch, |
| remote: 'origin/' + currentBranch, |
| }; |
| } |
|
|
| async diffRemote(): Promise<DiffResult | undefined> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (diffRemote)'); |
| } |
| const currentBranch = await this.getCurrentBranch(); |
| if (currentBranch.remote) { |
| const target = currentBranch.remote; |
| return await this.git.diffSummary(['...' + target, '--ignore-all-space']); |
| } |
| return; |
| } |
|
|
| async diffLocal(): Promise<DiffResult | undefined> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (diffLocal)'); |
| } |
| const currentBranch = await this.getCurrentBranch(); |
| if (currentBranch.remote) { |
| const target = currentBranch.current; |
| return await this.git.diffSummary([target, '--ignore-all-space']); |
| } |
| return; |
| } |
|
|
| async fetch(): Promise<FetchResult> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (fetch)'); |
| } |
| await this.setGitSshCommand(); |
| return await this.git.fetch(); |
| } |
|
|
| async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise<PullResult> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (pull)'); |
| } |
| await this.setGitSshCommand(); |
| const params = {}; |
| if (options.ffOnly) { |
| Object.assign(params, { '--ff-only': true }); |
| } |
| return await this.git.pull(params); |
| } |
|
|
| async push( |
| options: { force: boolean; branch: string } = { |
| force: false, |
| branch: SOURCE_CONTROL_DEFAULT_BRANCH, |
| }, |
| ): Promise<PushResult> { |
| const { force, branch } = options; |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized ({)'); |
| } |
| await this.setGitSshCommand(); |
| if (force) { |
| return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']); |
| } |
| return await this.git.push(SOURCE_CONTROL_ORIGIN, branch); |
| } |
|
|
| async stage(files: Set<string>, deletedFiles?: Set<string>): Promise<string> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (stage)'); |
| } |
| if (deletedFiles?.size) { |
| try { |
| await this.git.rm(Array.from(deletedFiles)); |
| } catch (error) { |
| this.logger.debug(`Git rm: ${(error as Error).message}`); |
| } |
| } |
| return await this.git.add(Array.from(files)); |
| } |
|
|
| async resetBranch( |
| options: { hard: boolean; target: string } = { hard: true, target: 'HEAD' }, |
| ): Promise<string> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (Promise)'); |
| } |
| if (options?.hard) { |
| return await this.git.raw(['reset', '--hard', options.target]); |
| } |
| return await this.git.raw(['reset', options.target]); |
| |
| |
| } |
|
|
| async commit(message: string): Promise<CommitResult> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (commit)'); |
| } |
| return await this.git.commit(message); |
| } |
|
|
| async status(): Promise<StatusResult> { |
| if (!this.git) { |
| throw new UnexpectedError('Git is not initialized (status)'); |
| } |
| const statusResult = await this.git.status(); |
| return statusResult; |
| } |
| } |
|
|