← Voltar para o blog
02 de junho de 2026*16 min de leitura

Construindo um sincronizador de pastas em Go (e a saga das pastas fantasmas)

Construí um sincronizador de pastas self-hosted em Go pra aprender a linguagem a fundo. No post: a arquitetura (servidor + daemon com worker pool), o merge de três vias que decide o que sincronizar, e a saga das "pastas fantasmas". Ótimo pra praticar concorrência, canais e select.

Repositório: github.com/seu-usuario/go-sync-folders

Há tempos eu queria uma forma simples de compartilhar as mesmas pastas entre meus dispositivos: o vault do Obsidian, uns diretórios de coisas que uso no dia a dia, esse tipo de coisa. Eu poderia usar Dropbox, Drive, Syncthing… mas duas coisas se juntaram: (1) a Oracle Cloud oferece uma VM gratuita bem generosa, e (2) eu queria mesmo era aprender Go de forma mais profunda. Então em vez de instalar algo pronto, decidi construir.

O ponto de partida foi este vídeo: How To Build A Complete Distributed File Storage In Golang — um tutorial longo (de verdade, são horas) que constrói um sistema de armazenamento de arquivos distribuído em Go do zero. Ele me deu a base mental; a partir daí o projeto tomou outro rumo, focado no meu caso de uso: espelhar pastas entre máquinas via um servidor central self-hosted.

Aviso honesto: isso começou como testes simples e fui lapidando por tentativa e erro até virar um código que eu considero bom. Ainda dá pra achar cantos toscos, herança dos experimentos. E eu não tratei isso como produto — então fique à vontade pra adaptar. O exemplo mais gritante: a "autenticação" é só um token comparado em tempo constante. Auth robusto e seguro eu já fiz muitas vezes; pra aprender, preferi gastar energia no miolo do problema (sincronização), não em reinventar login.


O que ele faz

A ideia é um "Dropbox minimalista e self-hosted pra Linux":

  • Um servidor central (syncdrive-server) guarda a cópia autoritativa dos arquivos e um manifesto (a lista de arquivos e pastas, com hash, tamanho e data).
  • Um daemon (syncdrive-daemon) roda em cada máquina, observa pastas locais e as mantém espelhadas com o servidor — arquivos e diretórios, incluindo pastas vazias.
        ┌───────────────────────────────────────────────┐
        │               syncdrive-server                │
        │   files/   +   manifest.json (files & dirs)   │
        └───────────────▲─────────────────▲─────────────┘
                        │  HTTP + Bearer  │
            ┌───────────┘                 └────────────┐
   ┌────────┴──────────┐                     ┌─────────┴────────┐
   │  syncdrive-daemon │                     │ syncdrive-daemon │
   │  watcher + workers│                     │ watcher + workers│
   └───────────────────┘                     └──────────────────┘
        máquina A                                  máquina B

Três modos por pasta: two-way (sobe e desce), push (só sobe) e pull (só desce).


O modelo de dados: o manifesto

Tudo gira em torno de metadados leves. Nada de mandar arquivo pra detectar mudança — comparamos metadados e só transferimos o que realmente mudou.

type FileMeta struct {
	Path    string    `json:"path"`
	Size    int64     `json:"size"`
	SHA256  string    `json:"sha256"`
	ModTime time.Time `json:"mod_time"`
}

type DirMeta struct {
	Path    string    `json:"path"`
	ModTime time.Time `json:"mod_time,omitempty"`
}

type Manifest struct {
	Files map[string]FileMeta `json:"files"`
	Dirs  map[string]DirMeta  `json:"dirs,omitempty"`
}

O servidor mantém esse manifesto. Cada cliente guarda, além disso, um estado local em .syncdrive/state.json: uma foto de "como as coisas estavam na última sincronização bem-sucedida". Esse estado é o que permite distinguir uma exclusão real de um arquivo que nunca chegou. Guarde esse detalhe — é o coração de tudo.


O coração: o merge de três vias

A pergunta central de qualquer sincronizador é: dado um caminho, o que eu faço? Subo? Desço? Apago? Ignoro?

A resposta vem de comparar três visões do mesmo caminho:

  1. Local — o que está no disco agora.
  2. Remoto — o que o manifesto do servidor diz.
  3. Estado — o que era verdade na última sync.

Com essas três, dá pra deduzir a intenção. Exemplo: o arquivo existe no servidor, não existe local, e estava no estado → significa que eu o apaguei desde a última sync, logo devo apagá-lo no servidor. Já se ele existe no servidor, não existe local, e não estava no estado → é um arquivo novo de outra máquina, devo baixar.

Toda essa decisão mora em uma única função pura, decideFile. Concentrar a lógica num lugar só foi uma das melhores decisões do projeto (antes ela estava duplicada e divergindo entre dois caminhos de código):

func decideFile(mode FolderMode, l FileMeta, hasLocal bool, r FileMeta, hasRemote bool, old FileMeta, hadOld bool) (fileAction, FileMeta) {
	switch {
	case hasLocal && !hasRemote:
		if mode == ModePull {
			return actKeep, l
		}
		if hadOld && old.SHA256 == l.SHA256 {
			return actDeleteLocal, l // estava no estado e some do remoto → exclusão
		}
		return actUpload, l // arquivo novo local → sobe

	case !hasLocal && hasRemote:
		if mode == ModePush {
			return actKeep, r
		}
		if hadOld && old.SHA256 == r.SHA256 {
			return actDeleteRemote, r
		}
		return actDownload, r

	case hasLocal && hasRemote:
		// ... two-way: se hashes batem, nada a fazer.
		if l.SHA256 != "" && l.SHA256 == r.SHA256 {
			return actKeep, l
		}
		localChanged := !hadOld || old.SHA256 != l.SHA256
		remoteChanged := !hadOld || old.SHA256 != r.SHA256
		switch {
		case localChanged && remoteChanged: // conflito real
			if l.ModTime.After(r.ModTime) {
				return actUpload, l // o mais novo vence
			}
			return actDownload, r
		case localChanged:
			return actUpload, l
		case remoteChanged:
			return actDownload, r
		}
	}
	return actNone, FileMeta{}
}

A política de conflito é "o mais recente vence" pelo ModTime, com o servidor como desempate em caso de empate (pra todas as máquinas convergirem). É simples, previsível, e suficiente pro meu uso.

Um detalhe que me agradou: o scan do disco não recalcula o SHA-256 de tudo a cada varredura. Se tamanho e data de modificação batem com o estado, ele reaproveita o hash já conhecido. Hashear arquivo grande à toa é caro; isso evita.


Concorrência e paralelismo: por que isso é ótimo pra aprender Go

Aqui o projeto ficou divertido de verdade, e é onde Go brilha. Tem goroutines, canais, worker pools, select, sync.Mutex, sync.WaitGroup — o pacote completo.

Pool de workers para transferências

As transferências (upload/download) rodam em paralelo num pool. Downloads vêm antes de uploads, e dentro de cada tipo os arquivos menores vão primeiro — assim um upload gigante não trava os pequenos:

ch := make(chan syncJob)
var wg sync.WaitGroup

for range workers {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for job := range ch { // recebe trabalho pelo canal
			switch job.kind {
			case jobUpload:
				result, err = s.upload(root, job.path, job.meta)
			case jobDownload:
				err = s.download(root, job.path, job.meta)
			}
			// ...grava o resultado no próximo estado (protegido por mutex)
		}
	}()
}

for _, job := range jobs {
	ch <- job // distribui
}
close(ch)  // fecha → os workers terminam o range e saem
wg.Wait()  // espera todos

Esse é o padrão clássico de fan-out com canal: produtor manda jobs, N workers consomem. close(ch) + range é a forma idiomática de sinalizar "acabou".

O daemon contínuo: um pool que nunca dorme

No modo daemon eu queria algo mais reativo: uma edição num .txt deve subir imediatamente, mesmo que um upload grande esteja ocupando outro worker. Então o FolderSyncer mantém um pool permanente de workers, e uma varredura só enfileira jobs e segue a vida — ela não espera as transferências.

Isso trouxe um problema bonito de concorrência: como coalescer varreduras? Se chegam 10 eventos do sistema de arquivos em sequência, eu não quero 10 varreduras empilhadas — quero uma, depois da última mudança. A solução é uma flag de "pendente":

func (fs *FolderSyncer) Trigger() {
	fs.scanMu.Lock()
	if fs.scanRunning {
		fs.scanPending = true   // já tem scan rodando? marca pra refazer no fim
		fs.scanMu.Unlock()
		return
	}
	fs.scanRunning = true
	fs.scanMu.Unlock()

	go func() {
		for {
			fs.scan()
			fs.scanMu.Lock()
			if !fs.scanPending {     // ninguém pediu de novo → encerra
				fs.scanRunning = false
				fs.scanMu.Unlock()
				return
			}
			fs.scanPending = false   // teve pedido durante o scan → roda mais uma vez
			fs.scanMu.Unlock()
		}
	}()
}

Garante um scan por vez (sem corrida) e pelo menos um após a última mudança (sem perder a "borda"). Coalescência honesta em ~15 linhas.

Observando o sistema de arquivos

O gatilho vem de um watcher sobre fsnotify, que acumula caminhos "sujos" e sinaliza sem bloquear:

func (w *Watcher) signal(path string) {
	w.dirtyMu.Lock()
	w.dirty[path] = struct{}{}
	w.dirtyMu.Unlock()
	select {
	case w.events <- struct{}{}: // canal com buffer 1: "tem novidade"
	default:                     // já tem sinal pendente → não bloqueia
	}
}

Esse select com default é um truque que eu passei a amar em Go: enviar num canal se possível, sem nunca travar.

A corrida que quase me pegou: parar o pool

Tem uma sutileza traiçoeira: se você fecha o canal de jobs (close(jobsCh)) no shutdown enquanto uma varredura ainda pode enfileirar um job, você toma um panic: send on closed channel. A correção idiomática é não fechar o canal de jobs; em vez disso, fechar um canal done e fazer todo mundo escutá-lo:

func (fs *FolderSyncer) Stop() { close(fs.done) } // só isso

func (fs *FolderSyncer) worker() {
	for {
		select {
		case <-fs.done:           // desligar
			return
		case job := <-fs.jobsCh:  // ou trabalhar
			// ...
		}
	}
}

// e o envio também observa o done:
go func() {
	select {
	case fs.jobsCh <- job:
	case <-fs.done: // abortando: ainda assim liberamos o WaitGroup
		fs.jobsWg.Done()
	}
}()

Pequeno, mas é exatamente o tipo de bug de concorrência que só aparece sob carga — e que te ensina a pensar em Go.


Como a sincronização roda hoje (varredura total)

Vou ser transparente sobre uma limitação atual: a reconciliação autoritativa é uma varredura total. A cada sync, o daemon percorre a árvore inteira da pasta local (filepath.WalkDir) e busca o manifesto completo do servidor, monta as três visões (local, remoto, estado) e roda o BuildPlan sobre a união de todos os caminhos.

Existe um atalho: quando o watcher avisa que um arquivo específico mudou, eu faço um fast-path só daquele caminho, pra ele subir na hora — mas, logo em seguida, ainda disparo a varredura completa pra reconciliar diretórios e qualquer coisa que o atalho não enxergue.

Na prática, pra pastas de tamanho normal (Obsidian, documentos), isso é rápido e funciona muito bem: comparar metadados é barato, o scan reaproveita o hash pelo tamanho+mtime, e o polling usa ETag (304 Not Modified) pra não rebaixar o manifesto à toa. Mas, claro, varrer a árvore inteira não escala de graça pra milhões de arquivos.

Ainda estou buscando alternativas pra melhorar essa parte — coisas como reconciliação incremental de verdade (confiar mais nos eventos do watcher), um índice/jornal de mudanças, ou comparar só as subárvores afetadas. Por enquanto, a varredura total é simples, previsível e me atende — então deixei assim de propósito, até achar uma abordagem melhor que valha a complexidade extra.


A saga das pastas fantasmas 👻

Esse foi o problema que mais me ensinou, e o motivo principal deste artigo.

Na primeira versão, eu sincronizava só arquivos. Diretórios eram "inferidos" dos caminhos dos arquivos. Parece esperto e econômico — e funciona, até não funcionar.

Sintoma: eu apagava uma pasta inteira numa máquina, e na outra só os arquivos sumiam. A pasta vazia ficava lá, encalhada. Pior: pastas vazias nunca sincronizavam. Apelidei o bug de pasta fantasma.

Por que acontecia? Porque o diretório não era uma entidade de verdade no sistema. O servidor chegava a ter um campo Dirs no manifesto, mas apagava ele em todo save (clear(s.manifest.Dirs)) e nem o devolvia na API. O cliente nunca registrava pastas. Sobravam funções pela metade, código morto de tentativas anteriores. A remoção de pasta dependia de uma heurística frágil de "limpar diretórios que ficaram vazios neste sync" — que não cobria pasta vazia de propósito, nem o caso de a pasta já estar vazia antes.

A virada foi tratar diretório como cidadão de primeira classe, exatamente como arquivo:

  1. O scan passou a registrar toda pasta (inclusive vazias).
  2. O servidor passou a persistir e devolver Dirs, e a registrar pastas ancestrais quando um arquivo sobe.
  3. A reconciliação virou um planejador puro, BuildPlan, que produz um plano completo: uploads, downloads, exclusões, e criação/remoção de diretórios.

O mesmo merge de três vias dos arquivos passou a valer pras pastas. E a ordem importa: criar pastas do raso pro fundo (o pai antes do filho), remover do fundo pro raso (filho antes do pai). Veja um pedaço do plano lidando com uma pasta que só existe no remoto:

case !hasL && hasR:
	if hadOld {
		// existia na última sync e sumiu local → espelha a remoção,
		// a menos que ainda haja conteúdo remoto vivo dentro dela
		if hasRemoteContent {
			p.NextDirs[key] = rd
		} else {
			p.RmdirRemote = append(p.RmdirRemote, key)
		}
	} else {
		// pasta nova no remoto: se já é "implícita" por um arquivo, o
		// download cria; senão, precisa de um mkdir explícito (pasta vazia)
		if hasRemoteContent {
			p.NextDirs[key] = rd
		} else {
			p.MkdirLocal = append(p.MkdirLocal, key)
		}
	}

Repare em dois detalhes que me deram orgulho:

  • Pasta vazia só precisa de mkdir explícito. Pasta com arquivo é criada de brinde quando o arquivo é escrito — então não desperdiço chamadas.
  • A guarda do conflito apagar-vs-criar. Se eu apago uma pasta numa máquina, mas outra adiciona um arquivo novo dentro dela ao mesmo tempo, o arquivo vence e a pasta sobrevive. Localmente isso cai naturalmente porque os.Remove só remove diretório vazio: se tem conteúdo novo, ele recusa e eu sigo a vida.
func (s *Syncer) removeLocalDir(root, dir string) {
	err := os.Remove(filepath.Join(root, filepath.FromSlash(dir)))
	if err == nil || errors.Is(err, os.ErrNotExist) || isNotEmpty(err) {
		return // sucesso, já não existia, ou tinha conteúdo novo → tudo certo
	}
	s.log("rmdir %s: %v", dir, err)
}

Resultado: criar, esvaziar ou excluir pastas — inclusive vazias — espelha de forma simétrica entre os dispositivos. Fantasma exorcizado. E o planejador puro ficou trivial de testar (mando três mapas, confiro o plano), o que me deu uma rede de segurança enorme.


Outros detalhes que valem o clique

Algumas coisinhas que aprendi a valorizar:

  • Hash enquanto faz streaming. No upload, o corpo passa por um io.TeeReader que alimenta o SHA-256 ao mesmo tempo em que envia. Não leio o arquivo duas vezes, e confiro no fim que o hash local bate com o que o servidor calculou.
  • Escritas atômicas. Arquivo baixado vai pra um temporário e só então um os.Rename atômico o coloca no lugar. Nunca fica um arquivo pela metade no destino.
  • Detectar arquivo mudando no meio do upload. Um stableFileReader checa, durante o envio, se tamanho/mtime mudaram — se mudaram, cancela o upload em vez de mandar algo inconsistente.
  • Placeholder .downloading. Enquanto baixa, aparece um arquivo.downloading na pasta, limpo automaticamente ao terminar, falhar ou reiniciar o daemon. Feedback visual barato.
  • Manifesto condicional com ETag. O polling manda If-None-Match; se nada mudou no servidor, ele responde 304 Not Modified. Economiza banda à toa.
  • Proteção contra path traversal. Caminhos com .., absolutos ou com \ são rejeitados antes de tocar o disco.

O que deixei de fora (de propósito)

Pra não vender o que não é:

  • Auth é só um token comparado com subtle.ConstantTimeCompare. Suficiente atrás de HTTPS pra uso pessoal; não é um sistema de contas. Trocar por algo robusto é um exercício à parte.
  • Sem transferência resumível. Arquivo grande interrompido recomeça do zero. Dava pra fazer em chunks, mas fugia do foco.
  • Não é um produto. É um projeto de aprendizado que uso de verdade. Adapte como quiser.

Por que é um ótimo projeto pra aprender Go

Se você quer subir de nível em Go além do "CRUD com net/http", recomendo construir algo assim. Num projeto só, você encosta em:

  • Concorrência e paralelismo de verdade: worker pools, fan-out por canais, sync.Mutex/RWMutex, WaitGroup, e o padrão done-channel pra shutdown limpo.
  • select idiomático: envio não-bloqueante, cancelamento, coalescência de eventos.
  • net/http dos dois lados: servidor com ServeMux, streaming de corpos grandes, cabeçalhos condicionais (ETag/304), e cliente reaproveitando conexões.
  • I/O sério: io.Reader/io.Writer, TeeReader, MultiWriter, escrita atômica, hashing em fluxo.
  • Design testável: extrair a decisão pra uma função pura (BuildPlan) e cobrir os casos difíceis sem subir servidor nenhum.
  • Caçar bugs de concorrência: rodar go test -race e descobrir, sob carga, aquele send on closed channel que nunca aparece no caminho feliz.

E, talvez o mais importante: um problema com profundidade real. "Sincronizar pastas" parece trivial até você encarar exclusões, conflitos, pastas vazias e corridas. É aí que o aprendizado mora.


Fechando

Comecei querendo só sincronizar meu Obsidian entre máquinas, aproveitando uma VM grátis da Oracle, e terminei com um sincronizador que entendo de ponta a ponta — e que me ensinou mais Go do que qualquer tutorial isolado. O código está aberto em github.com/LuizFernando991/go-sync-folders; clone, quebre, melhore, transforme no seu jeito.

Se for mexer, meu conselho: comece pelo decideFile e pelo BuildPlan. É ali que o sistema "pensa".