Custom merge drivers: resolvendo conflitos em arquivos binários ou especializados

1. Por que os merge drivers padrão falham em cenários especiais

O Git utiliza uma estratégia textual para resolução de conflitos: ele compara arquivos linha por linha, aplicando algoritmos como o "three-way merge" baseado no ancestral comum. Essa abordagem funciona bem para código-fonte, mas falha em cenários onde o conteúdo não é texto puro ou onde a estrutura do arquivo exige tratamento semântico.

Problemas típicos incluem:

  • Arquivos binários (imagens, PDFs, DLLs): o Git não consegue interpretar o conteúdo, resultando em conflitos impossíveis de resolver automaticamente. Cada merge gera um conflito que exige intervenção manual.
  • Formatos especializados (JSON, XML, arquivos de lock): o merge textual pode gerar falsos conflitos. Por exemplo, ao modificar chaves diferentes em um package-lock.json, o Git pode sinalizar conflito mesmo quando as alterações são compatíveis.
  • Consequências práticas: merges impossíveis, perda de dados (quando uma versão binária é sobrescrita incorretamente) e retrabalho manual constante.

2. Anatomia de um custom merge driver

Um custom merge driver é definido em duas etapas:

  1. No .gitattributes: associa um padrão de arquivo a um nome de driver.
  2. No .gitconfig: define o comportamento do driver.

A estrutura do driver no .gitconfig inclui:

[merge "meu-driver"]
    name = "Driver para arquivos binários"
    driver = script-merge.sh %O %A %B %P
    recursive = binary

Os parâmetros passados ao driver são:

  • %O: caminho temporário para a versão ancestral (base comum)
  • %A: caminho temporário para a versão "nossa" (branch atual)
  • %B: caminho temporário para a versão "deles" (branch sendo mesclada)
  • %P: caminho real do arquivo no repositório

O driver deve modificar o arquivo em %A com o resultado do merge. Se retornar código de saída diferente de zero, o Git considera que houve conflito.

3. Implementando um merge driver para arquivos binários

Para arquivos binários, uma abordagem comum é escolher automaticamente entre as versões. Exemplo simples que sempre aceita a versão "nossa":

#!/bin/bash
# driver-sempre-nosso.sh
# Sempre mantém a versão do branch atual (%A)
cp "$2" "$1"  # $2 é %A (já está em $1), mas garantimos
exit 0

Para um merge condicional baseado em data de modificação:

#!/bin/bash
# driver-mais-recente.sh
# Escolhe a versão mais recente baseada em timestamp

ANCESTRAL="$1"
NOSSO="$2"
DELES="$3"

# Compara timestamps de modificação
if [ "$NOSSO" -nt "$DELES" ]; then
    # Nossa versão é mais recente, mantém
    exit 0
else
    # Versão deles é mais recente, copia para nossa
    cp "$DELES" "$NOSSO"
    exit 0
fi

Para tratamento de erros com fallback:

#!/bin/bash
# driver-com-fallback.sh
if [ ! -f "$1" ] || [ ! -f "$2" ] || [ ! -f "$3" ]; then
    echo "ERRO: Arquivos temporários ausentes" >&2
    exit 1  # Delega ao merge manual
fi

# Tenta merge binário simples
if cmp -s "$1" "$2"; then
    # Nossa versão igual ao ancestral, usa deles
    cp "$3" "$2"
    exit 0
elif cmp -s "$1" "$3"; then
    # Versão deles igual ao ancestral, mantém nossa
    exit 0
else
    # Ambos modificados, mantém nossa versão com aviso
    echo "AVISO: Ambas versões modificadas, mantendo nossa" >&2
    exit 0
fi

4. Merge driver para formatos especializados (JSON, XML, lock files)

Para package-lock.json, uma estratégia de união pode mesclar chaves únicas. Exemplo com Python:

#!/usr/bin/env python3
# merge-lock.py - Merge driver para package-lock.json
import json
import sys

def merge_locks(ancestral, nosso, deles):
    with open(ancestral) as f:
        base = json.load(f)
    with open(nosso) as f:
        a = json.load(f)
    with open(deles) as f:
        b = json.load(f)

    # Merge profundo: combina dicionários recursivamente
    resultado = {}
    for chave in set(list(a.keys()) + list(b.keys())):
        if chave in a and chave in b:
            if isinstance(a[chave], dict) and isinstance(b[chave], dict):
                resultado[chave] = {**a[chave], **b[chave]}
            else:
                resultado[chave] = b[chave] if chave not in base else a[chave]
        elif chave in a:
            resultado[chave] = a[chave]
        else:
            resultado[chave] = b[chave]

    with open(nosso, 'w') as f:
        json.dump(resultado, f, indent=2)
    return 0

if __name__ == "__main__":
    sys.exit(merge_locks(sys.argv[1], sys.argv[2], sys.argv[3]))

Para merge inteligente de JSON com arrays sem duplicatas:

#!/usr/bin/env node
// merge-json.js
const fs = require('fs');

function mergeJSON(ancestral, nosso, deles) {
    const base = JSON.parse(fs.readFileSync(ancestral, 'utf8'));
    const a = JSON.parse(fs.readFileSync(nosso, 'utf8'));
    const b = JSON.parse(fs.readFileSync(deles, 'utf8'));

    function deepMerge(obj1, obj2) {
        const result = { ...obj1 };
        for (const key in obj2) {
            if (Array.isArray(obj2[key])) {
                result[key] = [...new Set([...(obj1[key] || []), ...obj2[key]])];
            } else if (typeof obj2[key] === 'object' && obj2[key] !== null) {
                result[key] = deepMerge(obj1[key] || {}, obj2[key]);
            } else {
                result[key] = obj2[key];
            }
        }
        return result;
    }

    const merged = deepMerge(a, b);
    fs.writeFileSync(nosso, JSON.stringify(merged, null, 2));
    return 0;
}

const [,, ancestral, nosso, deles] = process.argv;
process.exit(mergeJSON(ancestral, nosso, deles));

5. Configuração e ativação do driver no repositório

Registro global do driver (no ~/.gitconfig):

git config --global merge.meu-driver.driver "/caminho/para/script.sh %O %A %B %P"
git config --global merge.meu-driver.name "Meu Driver Customizado"

Ou local (no repositório):

git config merge.meu-driver.driver "/caminho/para/script.sh %O %A %B %P"

Arquivo .gitattributes para associar padrões:

*.bin merge=meu-driver
package-lock.json merge=lock-merge
*.json merge=json-merge

Testando o driver manualmente com git merge-file:

git merge-file -p nosso.txt base.txt deles.txt --merge-driver=meu-driver

6. Depuração e tratamento de conflitos complexos

Para capturar logs do driver, redirecione a saída de erro:

[merge "debug-driver"]
    driver = /script.sh %O %A %B %P 2>> /tmp/merge-debug.log

Use GIT_MERGE_VERBOSITY para diagnóstico:

GIT_MERGE_VERBOSITY=5 git merge branch-alvo

Variáveis de ambiente úteis:

  • GIT_MERGE_VERBOSITY: controla nível de detalhes (0-5)
  • GIT_TRACE: ativa rastreamento geral
  • GIT_TRACE_PERFORMANCE: medições de performance

Estratégias de fallback: se o driver retornar código 1, o Git marca o arquivo como conflitado e permite resolução manual. Use isso quando o merge automático não for possível.

7. Boas práticas e limitações

  • Versionamento do script: mantenha o script do driver dentro do repositório (ex.: scripts/merge-drivers/) e referencie-o por caminho relativo ou absoluto.
  • Portabilidade: evite dependências de sistema específicas. Use shebangs universais (#!/usr/bin/env python3) e teste em Linux, macOS e Windows (com Git Bash ou WSL).
  • Quando não usar: para arquivos de texto simples, o merge textual do Git é superior. Custom drivers são para casos onde a semântica do arquivo importa mais que as linhas.

Limitações importantes:

  • Drivers não funcionam em merges com --ours ou --theirs (que ignoram o driver)
  • O driver é executado para cada arquivo, não para o merge completo
  • Performance pode ser impactada se o script for complexo

Custom merge drivers são ferramentas poderosas para domar conflitos em arquivos que o Git não entende nativamente. Com eles, você automatiza decisões que antes exigiam intervenção manual, reduzindo retrabalho e prevenindo erros.

Referências