Safer Paste no macOS: como impedir o Replace destrutivo do Finder

Existe um comportamento do Finder que sempre me incomodou, mas recentemente passou a causar problemas reais no trabalho.

Ao copiar uma pasta sobre outra pasta de mesmo nome, o macOS pode oferecer Replace.

O problema é que esse "Replace" não significa simplesmente:

substitua os arquivos que existem nos dois lugares.

Ele pode significar:

substitua a pasta inteira pela pasta que estou copiando.

Isso é uma diferença enorme.

Imagine um projeto no destino:

Projeto/
├── .env
├── node_modules/
├── vendor/
├── storage/
├── config.php
└── src/

E uma pasta que estou copiando:

Projeto/
├── config.php
└── src/

Eu quero substituir config.php e os arquivos correspondentes dentro de src/.

Eu não quero que .env, node_modules, vendor, storage ou qualquer outro conteúdo existente apenas no destino desapareça.

Mas é justamente esse tipo de desastre que um Replace de pasta pode provocar.

O comportamento que eu queria

A regra é simples:

pastas sempre devem ser mescladas.

Se um arquivo existe nos dois lados, eu quero decidir o que fazer com ele.

Se um arquivo existe somente no destino, ele deve continuar lá.

Em outras palavras:

ORIGEM + DESTINO
       ↓
     MERGE

Nunca:

ORIGEM
   ↓
APAGA DESTINO
   ↓
COLOCA ORIGEM

Além disso, eu precisava continuar tendo a possibilidade de substituir arquivos deliberadamente.

Por exemplo, posso estar restaurando um backup manual mais antigo:

Backup/config.php        01/08
Projeto/config.php       20/08

Se eu escolher Substituir existentes, quero que a versão de 01/08 entre mesmo sendo mais antiga.

A data não importa nesse caso. Eu mandei substituir.

Como uso Git na maioria dos projetos, substituir arquivos de código deliberadamente é algo controlável. O problema realmente perigoso era o Finder apagar silenciosamente arquivos e diretórios que nem faziam parte da pasta que estava sendo copiada.

A solução

A solução foi interceptar apenas o:

⌘V

do Finder usando Hammerspoon e executar a cópia com rsync.

A principal regra da implementação é:

NUNCA usar --delete

Assim, um item existente somente no destino não é removido.

Quando existe conflito, o Safer Paste oferece seis opções:

★ Backup + mais novo vence

Mais novo vence

Backup + substituir existentes

Substituir existentes

Pular existentes

Cancelar

Backup + mais novo vence

Compara as datas.

O arquivo mais recente vence e qualquer arquivo do destino que for substituído é salvo antes em:

~/Safer Paste Backups/

Mais novo vence

Mesma lógica, mas sem criar backup.

Internamente:

rsync --update

Backup + substituir existentes

A origem vence nos arquivos coincidentes independentemente da data.

Antes da substituição, a versão anterior do destino recebe backup.

Essa é provavelmente a opção mais segura para restaurar deliberadamente um backup antigo.

Substituir existentes

A origem vence nos arquivos coincidentes, mesmo sendo mais antiga.

Mas as árvores continuam sendo mescladas.

Por exemplo:

DESTINO
Projeto/
├── .env
├── node_modules/
├── vendor/
├── config.php
└── src/

Origem:

Projeto/
├── config.php
└── src/

Resultado:

Projeto/
├── .env             ← continua
├── node_modules/    ← continua
├── vendor/          ← continua
├── config.php       ← origem substitui
└── src/             ← mesclado

Para essa operação uso:

rsync --checksum

Assim, se origem e destino forem diferentes, a origem vence mesmo se tiver timestamp mais antigo.

Pular existentes

Arquivos que já existem não são alterados.

Somente itens novos são acrescentados:

rsync --ignore-existing

Cancelar

Nada é alterado.

Instalando o Hammerspoon

Uso Homebrew:

brew install --cask hammerspoon

Depois:

open -a Hammerspoon

No macOS é necessário autorizar o Hammerspoon em:

Ajustes do Sistema
→ Privacidade e Segurança
→ Acessibilidade

Também pode ser solicitada permissão de automação para o Finder.

Estrutura da configuração

Crie:

mkdir -p ~/.hammerspoon

O ~/.hammerspoon/init.lua pode ser extremamente simples:

require("safe-paste")

E toda a implementação fica em:

~/.hammerspoon/safe-paste.lua

Código do Safer Paste

local FINDER = "com.apple.finder"

local saferPasteChooser = nil
local saferPasteEventTap = nil
local runningTasks = {}

local function normalize(path)
    if not path then
        return nil
    end

    if path ~= "/" then
        path = path:gsub("/+$", "")
    end

    return path
end

local function basename(path)
    path = normalize(path)

    return path:match("([^/]+)$")
end

local function dirname(path)
    path = normalize(path)

    local result = path:match("^(.*)/[^/]+$")

    if not result or result == "" then
        return "/"
    end

    return result
end

local function join(a, b)
    a = normalize(a)

    if a == "/" then
        return "/" .. b
    end

    return a .. "/" .. b
end

local function pathMode(path)
    local attrs = hs.fs.symlinkAttributes(path)

    if not attrs then
        return nil
    end

    return attrs.mode
end

local function mkdirp(path)
    if hs.fs.attributes(path, "mode") == "directory" then
        return true
    end

    local parent = dirname(path)

    if parent ~= path then
        if not mkdirp(parent) then
            return false
        end
    end

    if hs.fs.mkdir(path) then
        return true
    end

    return hs.fs.attributes(path, "mode") == "directory"
end

local function isEditingText()
    local ok, result = pcall(function()
        local system = hs.axuielement.systemWideElement()

        local element =
            system:attributeValue("AXFocusedUIElement")

        if not element then
            return false
        end

        local role =
            element:attributeValue("AXRole")

        local subrole =
            element:attributeValue("AXSubrole")

        return role == "AXTextField"
            or role == "AXTextArea"
            or role == "AXComboBox"
            or subrole == "AXSearchField"
    end)

    if not ok then
        return false
    end

    return result
end

local function finderDestination()
    local script = [[
        tell application "Finder"

            if (count of Finder windows) = 0 then
                return POSIX path of (path to desktop folder)
            end if

            set f to target of front Finder window as alias

            return POSIX path of f

        end tell
    ]]

    local ok, result =
        hs.osascript.applescript(script)

    if not ok then
        error(
            "Não foi possível determinar a pasta atual do Finder."
        )
    end

    return normalize(result)
end

local function clipboardFiles()
    local raw =
        hs.pasteboard.readURL(nil, true)

    if not raw then
        return nil
    end

    local result = {}
    local seen = {}

    local function addPath(path)
        if type(path) ~= "string"
            or path == "" then
            return
        end

        path = normalize(path)

        if not seen[path] then
            seen[path] = true
            table.insert(result, path)
        end
    end

    local function processURL(value)
        if type(value) == "string" then

            if value:sub(1, 1) == "/" then
                addPath(value)
                return
            end

            local parts =
                hs.http.urlParts(value)

            if parts
                and parts.isFileURL
                and parts.fileSystemRepresentation then

                addPath(
                    parts.fileSystemRepresentation
                )
            end

            return
        end

        if type(value) ~= "table" then
            return
        end

        if type(value.filePath) == "string" then
            addPath(value.filePath)
            return
        end

        if type(value.fileSystemRepresentation)
            == "string" then

            addPath(
                value.fileSystemRepresentation
            )

            return
        end

        local url =
            value.url
            or value.absoluteString
            or value.absoluteURL

        if type(url) == "string" then
            processURL(url)
            return
        end

        for _, item in ipairs(value) do
            processURL(item)
        end
    end

    processURL(raw)

    if #result == 0 then
        return nil
    end

    return result
end

local function validateNoRecursion(
    sources,
    destination
)
    destination = normalize(destination)

    for _, source in ipairs(sources) do
        source = normalize(source)

        if pathMode(source) == "directory" then

            if destination == source
                or destination:sub(
                    1,
                    #source + 1
                ) == source .. "/" then

                return false, source
            end
        end
    end

    return true
end

local function findTypeMismatch(
    source,
    destination
)
    local sourceMode =
        pathMode(source)

    local destinationMode =
        pathMode(destination)

    if not destinationMode then
        return nil
    end

    if sourceMode ~= destinationMode then
        return {
            source = source,
            destination = destination,
            sourceMode = sourceMode,
            destinationMode = destinationMode
        }
    end

    if sourceMode ~= "directory" then
        return nil
    end

    local iterator, directory =
        hs.fs.dir(source)

    if not iterator then
        return nil
    end

    for item in iterator, directory do
        if item ~= "." and item ~= ".." then

            local mismatch =
                findTypeMismatch(
                    join(source, item),
                    join(destination, item)
                )

            if mismatch then
                return mismatch
            end
        end
    end

    return nil
end

local function hasCollision(
    sources,
    destination
)
    local collisions = {}

    for _, source in ipairs(sources) do
        local target =
            join(
                destination,
                basename(source)
            )

        if pathMode(target) then
            table.insert(
                collisions,
                basename(source)
            )
        end
    end

    return #collisions > 0, collisions
end

local function sourcesAlreadyInDestination(
    sources,
    destination
)
    destination =
        normalize(destination)

    for _, source in ipairs(sources) do
        if normalize(
            dirname(source)
        ) ~= destination then

            return false
        end
    end

    return true
end

local function runRsync(
    sources,
    destination,
    strategy
)
    local args = {
        "-aE"
    }

    local backupDir = nil

    local function enableBackup()
        backupDir =
            os.getenv("HOME")
            .. "/Safer Paste Backups/"
            .. os.date("%Y-%m-%d_%H-%M-%S")

        if not mkdirp(backupDir) then
            error(
                "Não foi possível criar:\n"
                .. backupDir
            )
        end

        table.insert(
            args,
            "--backup"
        )

        table.insert(
            args,
            "--backup-dir=" .. backupDir
        )
    end

    if strategy == "update" then

        table.insert(
            args,
            "--update"
        )

    elseif strategy == "replace" then

        table.insert(
            args,
            "--checksum"
        )

    elseif strategy == "skip" then

        table.insert(
            args,
            "--ignore-existing"
        )

    elseif strategy == "backup-update" then

        table.insert(
            args,
            "--update"
        )

        enableBackup()

    elseif strategy == "backup-replace" then

        table.insert(
            args,
            "--checksum"
        )

        enableBackup()

    else
        return
    end

    table.insert(
        args,
        "--"
    )

    for _, source in ipairs(sources) do
        table.insert(
            args,
            source
        )
    end

    table.insert(
        args,
        destination
    )

    hs.alert.show(
        "Safer Paste: copiando…",
        1
    )

    local task

    task =
        hs.task.new(
            "/usr/bin/rsync",

            function(
                exitCode,
                stdout,
                stderr
            )
                runningTasks[task] = nil

                if exitCode == 0 then

                    if backupDir then
                        hs.alert.show(
                            "Safer Paste concluído\nBackup criado em ~/Safer Paste Backups",
                            3
                        )
                    else
                        hs.alert.show(
                            "Safer Paste concluído",
                            1.5
                        )
                    end

                else
                    hs.dialog.blockAlert(
                        "Erro no Safer Paste",
                        "rsync retornou código "
                            .. tostring(exitCode)
                            .. "\n\n"
                            .. tostring(stderr or ""),
                        "OK"
                    )
                end
            end,

            args
        )

    if not task then
        error(
            "Não foi possível iniciar /usr/bin/rsync."
        )
    end

    runningTasks[task] = true

    if not task:start() then
        runningTasks[task] = nil

        error(
            "Não foi possível iniciar o rsync."
        )
    end
end

local function showChooser(
    sources,
    destination
)
    saferPasteChooser =
        hs.chooser.new(
            function(choice)
                saferPasteChooser = nil

                if not choice then
                    return
                end

                if choice.action == "cancel" then
                    return
                end

                local ok, err =
                    xpcall(
                        function()
                            runRsync(
                                sources,
                                destination,
                                choice.action
                            )
                        end,

                        debug.traceback
                    )

                if not ok then
                    hs.dialog.blockAlert(
                        "Safer Paste",
                        "A operação foi cancelada por segurança.\n\n"
                            .. tostring(err),
                        "OK"
                    )
                end
            end
        )

    saferPasteChooser:choices({

        {
            text =
                "★ Backup + mais novo vence",

            subText =
                "Mescla tudo. O mais novo vence e arquivos substituídos recebem backup.",

            action =
                "backup-update"
        },

        {
            text =
                "Mais novo vence",

            subText =
                "Mescla tudo. Entre arquivos coincidentes, preserva o mais recente.",

            action =
                "update"
        },

        {
            text =
                "Backup + substituir existentes",

            subText =
                "A origem sempre vence nos arquivos coincidentes e o destino anterior recebe backup.",

            action =
                "backup-replace"
        },

        {
            text =
                "Substituir existentes",

            subText =
                "A origem sempre vence. Itens exclusivos do destino permanecem.",

            action =
                "replace"
        },

        {
            text =
                "Pular existentes",

            subText =
                "Só adiciona itens que ainda não existem no destino.",

            action =
                "skip"
        },

        {
            text =
                "Cancelar",

            subText =
                "Não altera absolutamente nada.",

            action =
                "cancel"
        }

    })

    saferPasteChooser:rows(6)

    saferPasteChooser:width(65)

    saferPasteChooser:placeholderText(
        "Safer Paste — escolha como tratar os conflitos"
    )

    saferPasteChooser:show()
end

local function showInternalError(err)
    hs.dialog.blockAlert(
        "Safer Paste — operação bloqueada",

        "Ocorreu um erro interno.\n\n"
            .. "Por segurança, o Paste foi cancelado "
            .. "e o Finder não recebeu o Command+V original.\n\n"
            .. tostring(err),

        "OK"
    )
end

local function executeSaferPaste(
    sources,
    destination
)
    local ok, err =
        xpcall(
            function()

                local valid, badSource =
                    validateNoRecursion(
                        sources,
                        destination
                    )

                if not valid then
                    hs.dialog.blockAlert(
                        "Safer Paste bloqueado",

                        "O destino está dentro da própria origem:\n\n"
                            .. tostring(badSource)
                            .. "\n\nNada foi alterado.",

                        "OK"
                    )

                    return
                end

                for _, source in ipairs(sources) do

                    local target =
                        join(
                            destination,
                            basename(source)
                        )

                    local mismatch =
                        findTypeMismatch(
                            source,
                            target
                        )

                    if mismatch then
                        hs.dialog.blockAlert(
                            "Safer Paste bloqueado",

                            "Existe um conflito entre arquivo e pasta:\n\n"
                                .. tostring(mismatch.source)
                                .. "\n\nversus\n\n"
                                .. tostring(mismatch.destination)
                                .. "\n\nNada foi alterado.",

                            "OK"
                        )

                        return
                    end
                end

                local collision =
                    hasCollision(
                        sources,
                        destination
                    )

                if collision then
                    showChooser(
                        sources,
                        destination
                    )

                    return
                end

                runRsync(
                    sources,
                    destination,
                    "update"
                )
            end,

            debug.traceback
        )

    if not ok then
        showInternalError(err)
    end
end

saferPasteEventTap =
    hs.eventtap.new(
        {
            hs.eventtap.event.types.keyDown
        },

        function(event)

            if event:getKeyCode()
                ~= hs.keycodes.map.v then

                return false
            end

            local flags =
                event:getFlags()

            if not flags:containExactly(
                {"cmd"}
            ) then

                return false
            end

            local app =
                hs.application.frontmostApplication()

            if not app
                or app:bundleID() ~= FINDER then

                return false
            end

            if isEditingText() then
                return false
            end

            local okClipboard,
                  sources =
                pcall(
                    clipboardFiles
                )

            if not okClipboard then
                hs.timer.doAfter(
                    0,
                    function()
                        showInternalError(
                            sources
                        )
                    end
                )

                return true
            end

            if not sources
                or #sources == 0 then

                return false
            end

            local okDestination,
                  destination =
                pcall(
                    finderDestination
                )

            if not okDestination then
                hs.timer.doAfter(
                    0,
                    function()
                        showInternalError(
                            destination
                        )
                    end
                )

                return true
            end

            if sourcesAlreadyInDestination(
                sources,
                destination
            ) then

                return false
            end

            hs.timer.doAfter(
                0,
                function()
                    executeSaferPaste(
                        sources,
                        destination
                    )
                end
            )

            return true
        end
    )

saferPasteEventTap:start()

if saferPasteEventTap:isEnabled() then
    print(
        "Safer Paste carregado com sucesso."
    )

    hs.alert.show(
        "Safer Paste ativo",
        2
    )
else
    print(
        "ERRO: Safer Paste não conseguiu ativar o Event Tap."
    )
end

Depois recarregue o Hammerspoon:

Hammerspoon
→ Reload Config

Ou simplesmente:

killall Hammerspoon 2>/dev/null || true
open -a Hammerspoon

Verificando se está ativo

No Console do Hammerspoon:

saferPasteEventTap:isEnabled()

O retorno deve ser:

true

Uma vantagem da implementação com hs.eventtap é que não preciso ficar habilitando e desabilitando um hotkey dependendo de qual aplicativo está aberto.

O ⌘V é observado continuamente.

Se não estou no Finder, o evento simplesmente passa normalmente.

Se estou editando texto no Finder, ele também passa normalmente.

Somente quando estou realmente colando arquivos ou pastas em outro diretório é que o Safer Paste assume a operação.

Proteções adicionais

Além de eliminar o Replace destrutivo, acrescentei algumas proteções.

Arquivo contra pasta

Se na origem existe:

config

como arquivo, mas no destino existe:

config/

como pasta, a operação é bloqueada.

O inverso também é bloqueado.

Isso evita deixar o rsync tomar uma decisão potencialmente destrutiva sobre tipos incompatíveis.

Pasta sendo copiada para dentro dela mesma

Também bloqueio algo como:

Projeto/

sendo copiado para:

Projeto/Backup/

quando isso produziria uma árvore recursiva.

Colar na própria pasta continua normal

Se eu copiar:

arquivo.txt

e der ⌘V dentro da mesma pasta, o Safer Paste não interfere.

O Finder continua podendo criar sua cópia normalmente.

Texto continua funcionando

Se estiver renomeando um arquivo no Finder e usar ⌘V, o texto é colado normalmente.

Também não existe interferência em:

  • Terminal
  • Safari
  • Zed
  • VS Code
  • TextEdit
  • Notes
  • outros aplicativos

O tratamento é específico para cópia de arquivos e pastas no Finder.

Os testes que fiz

Não quis confiar apenas na teoria. Testei os cenários separadamente.

Pular existentes

Origem:

arquivo.txt = versão nova
novo.txt

Destino:

arquivo.txt = versão antiga
local.txt

Resultado:

arquivo.txt = versão antiga
novo.txt     = copiado
local.txt    = preservado

Mais novo vence

Origem mais nova:

arquivo.txt = ORIGEM NOVA

Destino mais antigo:

arquivo.txt = DESTINO ANTIGO

Resultado:

ORIGEM NOVA

Quando o destino era mais novo, ele foi preservado.

Substituir existentes

Esse foi o teste mais importante.

A origem simulava um backup antigo:

Projeto/
├── config.txt
└── src/
    └── app.js

O destino possuía versões mais novas e também:

.env
node_modules/
vendor/
storage/uploads/

Escolhendo:

Substituir existentes

os arquivos antigos da origem substituíram deliberadamente os arquivos mais novos.

Mas:

.env
node_modules/
vendor/
storage/uploads/

permaneceram intactos.

Era exatamente o comportamento que eu procurava.

Backup + substituir existentes

Também validei a mesma restauração, mas criando backup das versões substituídas.

O diretório ficou parecido com:

~/Safer Paste Backups/2026-08-20_21-17-52/
└── Projeto/
    ├── config.txt
    └── src/
        └── app.js

E esses arquivos continham exatamente as versões do destino existentes antes da substituição.

Por que não simplesmente usar rsync manualmente?

Eu poderia executar:

rsync ...

sempre que precisasse copiar um projeto.

Mas esse não era realmente o problema.

O problema era meu fluxo natural:

⌘C
⌘V

Eu queria continuar usando o Finder normalmente, mas tirar dele a decisão perigosa de substituir uma árvore inteira.

Com o Hammerspoon, o fluxo continua sendo:

⌘C
⌘V

Só que agora existe uma camada de segurança no meio.

O ponto mais importante

Nenhuma estratégia do Safer Paste utiliza:

--delete

Essa é uma decisão proposital.

Se algo existe somente no destino, a cópia não deve presumir que aquilo deve desaparecer.

É justamente isso que protege coisas como:

.env
node_modules/
vendor/
storage/
uploads/
arquivos locais
configurações locais
artefatos não presentes no backup

Quando eu escolho Substituir existentes, estou dizendo:

substitua aquilo que existe nos dois lados.

Não:

transforme o destino numa cópia exata da origem.

Essa pequena diferença é o motivo de eu ter criado o Safer Paste.

Limitação atual

O Safer Paste protege especificamente:

⌘V

no Finder.

Ele não altera, por enquanto:

  • drag and drop entre pastas;
  • ⌥⌘V;
  • outras operações explícitas de movimentação do Finder.

Portanto, o Replace nativo ainda pode existir nesses fluxos.

Para mim, resolver primeiro o ⌘C + ⌘V já elimina a principal fonte do problema.

Resultado

O que antes era:

Copiar pasta
→ Finder pergunta Replace
→ risco de apagar o restante do destino

passou a ser:

Copiar pasta
→ Safer Paste
→ escolher a estratégia
→ merge das árvores
→ nenhum arquivo exclusivo do destino é apagado

É uma funcionalidade pequena, mas que eu realmente gostaria que o macOS tivesse nativamente.

Enquanto isso não existe, Hammerspoon + rsync resolveram o problema de uma forma simples, transparente e integrada ao meu fluxo normal de trabalho.


Publicado

em

por

Tags: