import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { tmpdir } from 'node:os' import { RelayContext } from './context' import { GitHandler } from './git-handler' import { createMockDispatcher, type MockDispatcher, type RelayDispatcher } from './git-handler-test-setup' describe('GitHandler pull reconciliation', () => { let dispatcher: MockDispatcher let tmpDir: string let gitEnv: NodeJS.ProcessEnv let previousGitConfigGlobal: string | undefined let previousGitConfigNosystem: string | undefined beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-pull-reconciliation-')) const globalGitConfigPath = path.join(tmpDir, 'global-gitconfig') writeFileSync(globalGitConfigPath, '') previousGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL previousGitConfigNosystem = process.env.GIT_CONFIG_NOSYSTEM process.env.GIT_CONFIG_GLOBAL = globalGitConfigPath process.env.GIT_CONFIG_NOSYSTEM = '1' gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: globalGitConfigPath, GIT_CONFIG_NOSYSTEM: '1' } dispatcher = createMockDispatcher() const ctx = new RelayContext() new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) }) afterEach(async () => { restoreGitEnv('GIT_CONFIG_GLOBAL', previousGitConfigGlobal) restoreGitEnv('GIT_CONFIG_NOSYSTEM', previousGitConfigNosystem) await fs.rm(tmpDir, { recursive: true, force: true }) }) function execGit(cwd: string, args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf-8', env: gitEnv, stdio: 'pipe' }) } function configureIdentity(cwd: string): void { execGit(cwd, ['config', 'user.email', 'test@test.com']) execGit(cwd, ['config', 'user.name', 'Test']) } function commitAll(cwd: string, message: string): void { execGit(cwd, ['add', '.']) execGit(cwd, ['commit', '-m', message]) } function createDivergentFixture(): string { const bareDir = path.join(tmpDir, 'origin.git') const consumerDir = path.join(tmpDir, 'consumer') const producerDir = path.join(tmpDir, 'producer') execGit(tmpDir, ['init', '--bare', bareDir]) execGit(tmpDir, ['clone', bareDir, consumerDir]) configureIdentity(consumerDir) writeFileSync(path.join(consumerDir, 'base.txt'), 'base\n') commitAll(consumerDir, 'initial') execGit(consumerDir, ['push', '--set-upstream', 'origin', 'HEAD']) execGit(tmpDir, ['clone', bareDir, producerDir]) configureIdentity(producerDir) writeFileSync(path.join(producerDir, 'remote.txt'), 'remote\n') commitAll(producerDir, 'remote') execGit(producerDir, ['push']) writeFileSync(path.join(consumerDir, 'local.txt'), 'local\n') commitAll(consumerDir, 'local') return consumerDir } it('falls back to a merge when divergent branches have no configured strategy', async () => { const consumerDir = createDivergentFixture() await expect( dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) ).resolves.not.toThrow() // Merge reconciliation yields a two-parent commit and both sides' files. const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) expect(parentRefs).toHaveLength(2) expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) expect(existsSync(path.join(consumerDir, 'local.txt'))).toBe(true) expect(execGit(consumerDir, ['status', '--short'])).toBe('') }, 15_000) it('preserves configured fast-forward-only pull semantics on divergent branches', async () => { const consumerDir = createDivergentFixture() execGit(consumerDir, ['config', 'pull.ff', 'only']) // Why: an explicit ff-only policy must still fail on divergence rather than // getting silently reconciled by the merge fallback. await expect( dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) ).rejects.toThrow() const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) expect(parentRefs).toHaveLength(1) expect(execGit(consumerDir, ['status', '--short'])).toBe('') }, 15_000) it('preserves configured rebase pull semantics', async () => { const consumerDir = createDivergentFixture() execGit(consumerDir, ['config', 'pull.rebase', 'true']) await expect( dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) ).resolves.not.toThrow() const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) expect(parentRefs).toHaveLength(1) expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) expect(execGit(consumerDir, ['status', '--short'])).toBe('') }, 15_000) }) function restoreGitEnv( name: 'GIT_CONFIG_GLOBAL' | 'GIT_CONFIG_NOSYSTEM', value: string | undefined ): void { if (value === undefined) { delete process.env[name] return } process.env[name] = value }