From b505ac2fe010fcc24b774c313d33dc099214e8ed Mon Sep 17 00:00:00 2001 From: Bernhard Guillon Date: Mon, 6 Jul 2026 21:15:17 +0200 Subject: Initial project scaffolding - main.go: CLI entry point with flag parsing - gitignore.go: .gitignore loading and parsing - sync.go: initial sync with manifest tracking - watcher.go: fsnotify-based file watcher with debouncing - Dockerfile: multi-stage build for Alpine runtime --- .gitignore | 2 + Dockerfile | 11 ++++ gitignore.go | 22 +++++++ go.mod | 10 +++ go.sum | 16 +++++ main.go | 37 +++++++++++ sync.go | 106 ++++++++++++++++++++++++++++++ watcher.go | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 414 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 gitignore.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 sync.go create mode 100644 watcher.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf87c58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +sourcewatch +docker-gcc-sync-setup.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb516c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /sourcewatch . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=builder /sourcewatch /usr/local/bin/sourcewatch +ENTRYPOINT ["sourcewatch"] diff --git a/gitignore.go b/gitignore.go new file mode 100644 index 0000000..f6816bd --- /dev/null +++ b/gitignore.go @@ -0,0 +1,22 @@ +package main + +import ( + "os" + "path/filepath" + + ignore "github.com/sabhiram/go-gitignore" +) + +func loadGitignore(hostDir string) (*ignore.GitIgnore, error) { + gitignorePath := filepath.Join(hostDir, ".gitignore") + + data, err := os.ReadFile(gitignorePath) + if err != nil { + if os.IsNotExist(err) { + return ignore.CompileIgnoreLines(), nil + } + return nil, err + } + + return ignore.CompileIgnoreLines(string(data)), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c8474f --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module sourcewatch + +go 1.26.4 + +require ( + github.com/fsnotify/fsnotify v1.10.1 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 +) + +require golang.org/x/sys v0.13.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0cc9955 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..345436f --- /dev/null +++ b/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "flag" + "log" +) + +var ( + hostDir = flag.String("host", "/host", "Host source directory (read-only)") + dataDir = flag.String("data", "/data", "Data destination directory") + verbose = flag.Bool("verbose", false, "Enable verbose logging") + dryRun = flag.Bool("dry-run", false, "Preview what would be synced without copying") +) + +func main() { + flag.Parse() + log.SetFlags(log.LstdFlags | log.Lshortfile) + + if *verbose { + log.Println("sourcewatch starting...") + log.Printf("host: %s, data: %s, dry-run: %v", *hostDir, *dataDir, *dryRun) + } + + rules, err := loadGitignore(*hostDir) + if err != nil { + log.Fatalf("failed to load .gitignore: %v", err) + } + + manifest, err := initialSync(*hostDir, *dataDir, rules, *dryRun) + if err != nil { + log.Fatalf("initial sync failed: %v", err) + } + + if err := watch(*hostDir, *dataDir, manifest, rules, *dryRun, *verbose); err != nil { + log.Fatalf("watcher failed: %v", err) + } +} diff --git a/sync.go b/sync.go new file mode 100644 index 0000000..293f20b --- /dev/null +++ b/sync.go @@ -0,0 +1,106 @@ +package main + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + + ignore "github.com/sabhiram/go-gitignore" +) + +type FileMeta struct { + Size int64 + Mode os.FileMode + Symlink string +} + +type Manifest struct { + Files map[string]FileMeta +} + +func initialSync(hostDir, dataDir string, rules *ignore.GitIgnore, dryRun bool) (*Manifest, error) { + manifest := &Manifest{Files: make(map[string]FileMeta)} + + err := filepath.Walk(hostDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(hostDir, path) + if err != nil { + return err + } + + if relPath == "." { + return nil + } + + if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { + return filepath.SkipDir + } + + if rules.MatchesPath(relPath) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + destPath := filepath.Join(dataDir, relPath) + + linkTarget := "" + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err = os.Readlink(path) + if err != nil { + return err + } + } + + meta := FileMeta{ + Size: info.Size(), + Mode: info.Mode(), + Symlink: linkTarget, + } + manifest.Files[relPath] = meta + + if dryRun { + log.Printf("[dry-run] would sync: %s", relPath) + return nil + } + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return err + } + + if linkTarget != "" { + return os.Symlink(linkTarget, destPath) + } + + return copyFile(path, destPath, info.Mode()) + }) + + return manifest, err +} + +func copyFile(src, dst string, mode os.FileMode) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..7b823da --- /dev/null +++ b/watcher.go @@ -0,0 +1,210 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + ignore "github.com/sabhiram/go-gitignore" +) + +func watch(hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + + var mu sync.Mutex + debounce := make(map[string]*time.Timer) + + if err := addWatchersRecursively(watcher, hostDir, rules); err != nil { + return err + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + relPath, err := filepath.Rel(hostDir, event.Name) + if err != nil { + continue + } + + if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { + continue + } + + if rules.MatchesPath(relPath) { + continue + } + + if verbose { + log.Printf("event: %s %s", event.Op, relPath) + } + + mu.Lock() + if t, exists := debounce[relPath]; exists { + t.Stop() + } + debounce[relPath] = time.AfterFunc(200*time.Millisecond, func() { + mu.Lock() + delete(debounce, relPath) + mu.Unlock() + handleEvent(event.Op, hostDir, dataDir, relPath, manifest, rules, dryRun, verbose, watcher) + }) + mu.Unlock() + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + log.Printf("watcher error: %v", err) + } + } +} + +func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool, watcher *fsnotify.Watcher) { + destPath := filepath.Join(dataDir, relPath) + srcPath := filepath.Join(hostDir, relPath) + + switch { + case op&fsnotify.Create != 0: + syncCreate(srcPath, destPath, relPath, manifest, dryRun, verbose, watcher) + + case op&fsnotify.Write != 0: + syncWrite(srcPath, destPath, relPath, manifest, dryRun, verbose) + + case op&fsnotify.Remove != 0: + syncRemove(destPath, relPath, manifest, dryRun, verbose) + + case op&fsnotify.Rename != 0: + syncRemove(destPath, relPath, manifest, dryRun, verbose) + } +} + +func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, dryRun, verbose bool, watcher *fsnotify.Watcher) { + info, err := os.Lstat(srcPath) + if err != nil { + if os.IsNotExist(err) { + return + } + log.Printf("error stat %s: %v", relPath, err) + return + } + + if info.IsDir() { + if err := watcher.Add(srcPath); err != nil { + log.Printf("error adding watcher for %s: %v", relPath, err) + } + if dryRun { + log.Printf("[dry-run] would create dir: %s", relPath) + return + } + if err := os.MkdirAll(destPath, info.Mode()); err != nil { + log.Printf("error creating dir %s: %v", relPath, err) + } + return + } + + linkTarget := "" + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, _ = os.Readlink(srcPath) + } + + meta := FileMeta{ + Size: info.Size(), + Mode: info.Mode(), + Symlink: linkTarget, + } + manifest.Files[relPath] = meta + + if dryRun { + log.Printf("[dry-run] would sync: %s", relPath) + return + } + + os.MkdirAll(filepath.Dir(destPath), 0755) + + if linkTarget != "" { + if err := os.Symlink(linkTarget, destPath); err != nil { + log.Printf("error creating symlink %s: %v", relPath, err) + } + return + } + + if err := copyFile(srcPath, destPath, info.Mode()); err != nil { + log.Printf("error copying %s: %v", relPath, err) + } +} + +func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, verbose bool) { + info, err := os.Lstat(srcPath) + if err != nil { + if os.IsNotExist(err) { + return + } + log.Printf("error stat %s: %v", relPath, err) + return + } + + meta, exists := manifest.Files[relPath] + if exists && meta.Size == info.Size() && meta.Mode == info.Mode() { + return + } + + manifest.Files[relPath] = FileMeta{ + Size: info.Size(), + Mode: info.Mode(), + } + + if dryRun { + log.Printf("[dry-run] would update: %s", relPath) + return + } + + if err := copyFile(srcPath, destPath, info.Mode()); err != nil { + log.Printf("error copying %s: %v", relPath, err) + } +} + +func syncRemove(destPath, relPath string, manifest *Manifest, dryRun, verbose bool) { + delete(manifest.Files, relPath) + + if dryRun { + log.Printf("[dry-run] would remove: %s", relPath) + return + } + + os.Remove(destPath) +} + +func addWatchersRecursively(watcher *fsnotify.Watcher, root string, rules *ignore.GitIgnore) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + + if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { + return filepath.SkipDir + } + + if info.IsDir() && !rules.MatchesPath(relPath) { + return watcher.Add(path) + } + + return nil + }) +} -- cgit v1.2.3