Skip to content

Instantly share code, notes, and snippets.

@xandreafonso
Created March 29, 2026 05:39
Show Gist options
  • Select an option

  • Save xandreafonso/ce1ccdab92a66e12a6ddf6d63e584292 to your computer and use it in GitHub Desktop.

Select an option

Save xandreafonso/ce1ccdab92a66e12a6ddf6d63e584292 to your computer and use it in GitHub Desktop.
Watch-And-Sync.ps1 — Real-time file watcher that syncs local changes to a remote machine via SCP. Uses FileSystemWatcher + trailing-edge debounce to detect IDE saves and transfer only what changed, preserving directory structure. Great for syncing a local dev environment with a remote notebook/server over SSH.
<#
.SYNOPSIS
Vigila pastas de projeto em tempo real e (futuramente) sincroniza arquivos alterados com um notebook remoto via SCP.
.DESCRIPTION
Este script usa FileSystemWatcher para monitorar em tempo real qualquer arquivo salvo nas pastas especificadas.
Diferente do Sync-GitChanges.ps1 (que usa git status e é executado manualmente), este script fica rodando
continuamente e reage a cada salvamento feito pela IDE.
A estrutura de pastas é preservada no destino através do mapeamento:
LocalRoot → RemotePath
Exemplo: se LocalRoot = C:\Users\user\Workspace\my-project e um arquivo for salvo em
C:\Users\user\Workspace\my-project\app-backend\src\app.ts
o destino remoto será RemotePath\app-backend\src\app.ts
.PARAMETER WatchPaths
Lista de pastas a serem vigiadas em tempo real.
Padrão: src/ de goals-backend e goals-frontend.
.PARAMETER LocalRoot
Pasta raiz local usada como base para calcular o caminho relativo dos arquivos.
Padrão: pasta pai do script (ou seja, a raiz do workspace).
.PARAMETER RemoteUser
O nome de usuário no notebook de destino. (Padrão: your-user)
.PARAMETER RemoteHost
O endereço IP ou nome do host do notebook de destino. (Padrão: 192.168.1.x)
.PARAMETER RemotePath
O caminho base no notebook de destino. (Padrão: C:\Users\your-user\Workspace\my-project)
.PARAMETER SSHKeyPath
O caminho para a sua chave privada SSH. (Padrão: $HOME\.ssh\id_rsa)
.PARAMETER Debouncems
Intervalo mínimo em milissegundos entre dois eventos do mesmo arquivo.
Evita múltiplos disparos para um único salvamento. (Padrão: 500)
.EXAMPLE
.\Watch-And-Sync.ps1
# Usa todos os valores padrão. Vigila src/ de ambos os projetos.
.EXAMPLE
.\Watch-And-Sync.ps1 -RemoteHost "192.168.1.20"
# Sobrescreve apenas o host, mantendo os demais padrões.
.EXAMPLE
.\Watch-And-Sync.ps1 -WatchPaths @("C:\Projetos\app\src", "C:\Projetos\api\src")
# Vigila pastas customizadas.
#>
param (
[string[]]$WatchPaths = @(
"$PSScriptRoot\app-backend",
"$PSScriptRoot\app-frontend"
),
[string]$LocalRoot = $PSScriptRoot,
[string]$RemoteUser = "your-user",
[string]$RemoteHost = "192.168.1.x",
[string]$RemotePath = "C:\Users\your-user\Workspace\my-project",
[string]$SSHKeyPath = "$HOME\.ssh\id_rsa",
[int] $DebounceMs = 3500
)
# ============================================================================
# EXTENSÕES IGNORADAS
# Arquivos temporários criados pela IDE durante o salvamento.
# ============================================================================
$IgnoredExtensions = @(".tmp", ".swp", ".bak", "~", ".orig")
# ============================================================================
# PASTAS IGNORADAS
# Se qualquer segmento do caminho do arquivo corresponder a um nome desta lista,
# o evento é silenciado — o arquivo não é enfileirado nem enviado.
# ============================================================================
$IgnoredFolders = @(
"node_modules"
".git"
".next"
".nuxt"
".temp"
"dist"
"build"
".cache"
".turbo"
".svelte-kit"
".vscode"
".idea"
"__pycache__"
)
# ============================================================================
# CONFIGURAÇÃO SSH/SCP
# ============================================================================
$sshKeyArgs = @()
$scpKeyArgs = @()
if (Test-Path $SSHKeyPath) {
$sshKeyArgs = @("-i", $SSHKeyPath)
$scpKeyArgs = @("-i", $SSHKeyPath)
}
else {
Write-Warning "Chave SSH nao encontrada em '$SSHKeyPath'. Usando configuracao padrao do SSH."
}
# ============================================================================
# FILA DE ENVIO — arquivos aguardando debounce antes de serem enviados
# Chave: caminho do arquivo | Valor: @{ Time = ultimo evento; ChangeType = tipo }
# Trailing-edge debounce: o arquivo só é enviado após DebounceMs sem novos eventos.
# Isso garante que apenas um envio acontece mesmo que o formatador (Biome, Prettier)
# salve o arquivo novamente logo após o salvamento manual.
# ============================================================================
$pendingFiles = [System.Collections.Hashtable]::Synchronized(@{})
# ============================================================================
# Função: Invoke-FileSync
# Envia um arquivo já "settled" para o notebook remoto via SCP.
# Chamada pelo loop principal após o debounce, nunca diretamente pelos eventos.
# ============================================================================
function Invoke-FileSync {
param (
[string]$FilePath,
[string]$ChangeType
)
# Calcular caminho relativo ao LocalRoot
$relativePath = $FilePath
if ($FilePath.StartsWith($LocalRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
$relativePath = $FilePath.Substring($LocalRoot.Length).TrimStart('\', '/')
}
# -----------------------------------------------------------------
# Sincronizar via SCP com o notebook remoto
# -----------------------------------------------------------------
$normalizedRelative = $relativePath.Replace('\', '/')
$remoteFileDest = ("$RemotePath/$normalizedRelative").Replace('\', '/').Replace('//', '/')
$remoteDir = ($remoteFileDest | Split-Path -Parent)
# Garantir que o diretório remoto existe
$mkdirCmd = "powershell -Command `"if (-not (Test-Path '$remoteDir')) { New-Item -ItemType Directory -Force -Path '$remoteDir' | Out-Null }`""
$sshCmdStr = "ssh $($sshKeyArgs -join ' ') $RemoteUser@$RemoteHost `"mkdir '$remoteDir'`""
Write-Host " $ $sshCmdStr" -ForegroundColor DarkGray
ssh @sshKeyArgs "$RemoteUser@$RemoteHost" $mkdirCmd 2>$null
# Enviar arquivo via SCP
$scpCmdStr = "scp $($scpKeyArgs -join ' ') `"$FilePath`" `"${RemoteUser}@${RemoteHost}:$remoteFileDest`""
Write-Host " $ $scpCmdStr" -ForegroundColor DarkGray
scp @scpKeyArgs "$FilePath" "${RemoteUser}@${RemoteHost}:$remoteFileDest" >$null 2>&1
$fileName = Split-Path $FilePath -Leaf
if ($LASTEXITCODE -eq 0) {
Write-Host " -> Enviado com sucesso ($fileName)" -ForegroundColor DarkGreen
}
else {
Write-Host " -> FALHA no envio ($fileName)" -ForegroundColor Red
}
}
# ============================================================================
# REGISTRAR WATCHERS
# Eventos são enfileirados por SourceIdentifier e processados no loop principal,
# onde a função Invoke-FileSync e todas as variáveis estão em escopo.
# ============================================================================
$watchers = @()
$i = 0
foreach ($watchPath in $WatchPaths) {
$resolvedPath = Resolve-Path -Path $watchPath -ErrorAction SilentlyContinue
if (-not $resolvedPath) {
Write-Warning "Pasta nao encontrada, ignorando: $watchPath"
continue
}
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $resolvedPath.Path
$watcher.IncludeSubdirectories = $true
$watcher.EnableRaisingEvents = $true
$watcher.NotifyFilter = (
[System.IO.NotifyFilters]::LastWrite -bor
[System.IO.NotifyFilters]::FileName
)
# Registrar sem action block — eventos vão para a fila global de eventos do PS
Register-ObjectEvent $watcher "Changed" -SourceIdentifier "WAS_Changed_$i" | Out-Null
Register-ObjectEvent $watcher "Created" -SourceIdentifier "WAS_Created_$i" | Out-Null
Register-ObjectEvent $watcher "Renamed" -SourceIdentifier "WAS_Renamed_$i" | Out-Null
$watchers += $watcher
$i++
Write-Host " Vigiando: " -NoNewline -ForegroundColor DarkGray
Write-Host $resolvedPath.Path -ForegroundColor White
}
# ============================================================================
# INÍCIO
# ============================================================================
Write-Host ""
Write-Host "--- Watch-And-Sync ---" -ForegroundColor Cyan
Write-Host "Local Root: $LocalRoot" -ForegroundColor DarkGray
Write-Host "Remoto: $RemoteUser@${RemoteHost}:$RemotePath" -ForegroundColor DarkGray
Write-Host ""
if ($watchers.Count -eq 0) {
Write-Error "Nenhuma pasta valida para vigiar. Encerrando."
exit 1
}
Write-Host ""
Write-Host "Aguardando alteracoes... Pressione Ctrl+C para parar." -ForegroundColor DarkGray
Write-Host ""
# Loop principal — trailing-edge debounce
#
# Fase 1: drena a fila de eventos do PS e registra arquivos em pendingFiles.
# Se o mesmo arquivo gerar um novo evento (ex: formatador), o timer é resetado.
# Fase 2: verifica quais arquivos estão "settled" (sem novos eventos por DebounceMs)
# e os envia via SCP.
try {
while ($true) {
# --- Fase 1: enfileirar eventos ---
$newEvents = Get-Event -ErrorAction SilentlyContinue |
Where-Object { $_.SourceIdentifier -like "WAS_*" }
foreach ($ev in $newEvents) {
$filePath = $ev.SourceEventArgs.FullPath
$changeType = $ev.SourceEventArgs.ChangeType
$ext = [System.IO.Path]::GetExtension($filePath)
# Verificar se algum segmento do caminho é uma pasta ignorada
$pathSegments = $filePath.Split([System.IO.Path]::DirectorySeparatorChar)
$inIgnoredFolder = $pathSegments | Where-Object { $IgnoredFolders -contains $_ }
if ($IgnoredExtensions -notcontains $ext -and -not $inIgnoredFolder) {
$now = [DateTime]::Now
$isNew = -not $pendingFiles.ContainsKey($filePath)
$isSignificant = -not $isNew -and ($now - $pendingFiles[$filePath].Time).TotalMilliseconds -gt 500
# Upsert: reseta o timer (trailing-edge)
$pendingFiles[$filePath] = @{ Time = $now; ChangeType = $changeType }
# Imprimir apenas na 1ª detecção ou num reset significativo (ex: Biome)
# Eventos duplicados rápidos do OS (NTFS) são silenciados
if ($isNew -or $isSignificant) {
$relPath = $filePath
if ($filePath.StartsWith($LocalRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
$relPath = $filePath.Substring($LocalRoot.Length).TrimStart([char]'\', [char]'/')
}
$typeColor = switch ($changeType) {
"Changed" { "Cyan" }
"Created" { "Green" }
"Renamed" { "Yellow" }
default { "White" }
}
$label = if ($isSignificant) { "[reset] " } else { "" }
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] " -NoNewline -ForegroundColor DarkGray
Write-Host "[$changeType] " -NoNewline -ForegroundColor $typeColor
Write-Host "$label$relPath" -NoNewline -ForegroundColor White
Write-Host " (debounce: ${DebounceMs}ms)" -ForegroundColor DarkYellow
}
}
Remove-Event -EventIdentifier $ev.EventIdentifier
}
# --- Fase 2: enviar arquivos settled ---
$now = [DateTime]::Now
$toSend = @($pendingFiles.GetEnumerator() |
Where-Object { ($now - $_.Value.Time).TotalMilliseconds -ge $DebounceMs })
foreach ($entry in $toSend) {
$pendingFiles.Remove($entry.Key)
Invoke-FileSync -FilePath $entry.Key -ChangeType $entry.Value.ChangeType
}
Start-Sleep -Milliseconds 200
}
}
finally {
# Limpeza: cancelar eventos e liberar watchers
Get-EventSubscriber -ErrorAction SilentlyContinue |
Where-Object { $_.SourceIdentifier -like "WAS_*" } |
ForEach-Object { Unregister-Event -SubscriptionId $_.SubscriptionId }
$watchers | ForEach-Object { $_.Dispose() }
Write-Host "`nWatcher encerrado." -ForegroundColor DarkGray
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment