import { GitPushFailure } from '../domain/git_push_failure'; import { captureGitPushDiagnostic } from './git_push_diagnostic'; import { verificationExecutable } from './verification_executable'; import { nativeVerificationRetainsProfile } from './native_verification_profiles'; import * as exec from '@actions/exec'; import type { GitCommitPort } from '../application/ports/git_ports'; import { buildGitAuthenticationEnvironment } from './git_authentication_environment'; import { prepareUntrustedCommandEnvironment } from './untrusted_command_environment'; import { withoutGitHooks } from './git_hook_isolation'; import { assertVerificationRuntimePrerequisites } from './verification_runtime_preflight'; import { DISABLED_VERIFICATION_SETUP, type PreparedVerificationSetup } from '../application/ports/verification_setup_ports'; import { prepareRepositoryVerification } from './verification_setup_runner'; import { captureSourceDependencyPreparation, type SourceDependencyPreparation } from './source_verification_dependencies'; import { captureVerificationPythonLibraries } from './verification_python_library_environment'; type GitCommandOptions = { stdout?: (data: Buffer) => void; stderr?: (data: Buffer) => void; env?: Record; untrusted?: boolean; verifyRuntimePrerequisites?: boolean; resolveFromEnvironment?: boolean }; type GitCommandExecutor = (program: string, args: string[], options?: GitCommandOptions) => Promise; export class GitCommitAdapter implements GitCommitPort { private readonly preparePythonLibraries: (environment: Record) => void; constructor( private readonly executeCommand: GitCommandExecutor = (program, args, options) => options ? exec.exec(options.resolveFromEnvironment && options.env ? verificationExecutable(program, options.env) : program, args, { // Captured Git reads can contain NUL-delimited records that // hide subsequent lines in GitHub's Actions log viewer. ...(options.stdout || options.stderr ? { silent: true } : {}), ...(options.stdout || options.stderr ? { listeners: { ...(options.stdout ? { stdout: options.stdout } : {}), ...(options.stderr ? { stderr: options.stderr } : {}) } } : {}), ...(options.env ? { env: options.env } : {}), }) : exec.exec(program, args), private readonly checkPrerequisites: (environment: Record) => void = assertVerificationRuntimePrerequisites, private readonly verificationSetup: PreparedVerificationSetup = DISABLED_VERIFICATION_SETUP, private readonly prepareSourceDependencies: SourceDependencyPreparation = captureSourceDependencyPreparation(), ) { this.preparePythonLibraries = captureVerificationPythonLibraries( verificationSetup.kind === 'ready' ? verificationSetup.toolRoot : process.env.RUNNER_TOOL_CACHE, ); } async execute(program: string, args: string[], options?: GitCommandOptions): Promise { if (options?.verifyRuntimePrerequisites && !options.untrusted) throw new Error('Runtime preflight requires credential-free verification.'); if (!options?.untrusted) return options ? this.executeCommand(program, args, options) : this.executeCommand(program, args); if (options.env) throw new Error('Untrusted command execution does not accept a caller-supplied environment.'); const runtime = prepareUntrustedCommandEnvironment(); try { if (options.verifyRuntimePrerequisites) { await prepareRepositoryVerification(this.verificationSetup, runtime.environment); this.preparePythonLibraries(runtime.environment); this.checkPrerequisites(runtime.environment); await this.prepareSourceDependencies(runtime.environment); } return await this.executeCommand(program, args, { ...(options.stdout ? { stdout: options.stdout } : {}), env: runtime.environment, ...(options.verifyRuntimePrerequisites && this.verificationSetup.kind === 'ready' ? { resolveFromEnvironment: true } : {}), }); } finally { if (!nativeVerificationRetainsProfile(runtime.environment.HOME)) runtime.cleanup(); } } async configureAuthor(name: string, email: string): Promise { await this.execute('git', ['config', 'user.name', name]); await this.execute('git', ['config', 'user.email', email]); } async fetch(branch: string, token?: string): Promise { if (await this.executeAuthenticated(['fetch', 'origin', branch], token) !== 0) { throw new Error('Git fetch did not complete successfully.'); } } async stageAll(): Promise { await this.execute('git', ['add', '-A']); } async stagePaths(paths: string[]): Promise { if (paths.length > 0) await this.execute('git', ['add', '--', ...paths]); } async commit(message: string): Promise { await withoutGitHooks(emptyHooks => this.execute('git', ['-c', `core.hooksPath=${emptyHooks}`, 'commit', '-m', message], { untrusted: true })); } async push(branch: string, token?: string): Promise { const diagnostic = captureGitPushDiagnostic(); try { const code = await this.executeAuthenticated(['push', 'origin', branch], token, diagnostic.stderr); if (code !== 0) throw new GitPushFailure(diagnostic.classify()); } catch { throw new GitPushFailure(diagnostic.classify()); } } private async executeAuthenticated(args: string[], token?: string, stderr?: (data: Buffer) => void): Promise { if (!token?.trim()) { return stderr ? this.execute('git', args, { stderr }) : this.execute('git', args); } const environment = buildGitAuthenticationEnvironment(token); return this.execute('git', args, { ...(stderr ? { stderr } : {}), // Supply authentication only to this trusted git subprocess. The // agent process never receives this value and nothing is persisted // in the repository's git configuration or remote URL. ...(environment ? { env: environment } : {}), }); } }