Created
March 29, 2026 05:39
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .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