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.