aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:15:17 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:15:17 +0200
commitb505ac2fe010fcc24b774c313d33dc099214e8ed (patch)
treeff3f6472c84abd45e7918722e70ee681b859b780
downloadsourcewatch-b505ac2fe010fcc24b774c313d33dc099214e8ed.tar.gz
sourcewatch-b505ac2fe010fcc24b774c313d33dc099214e8ed.zip
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
-rw-r--r--.gitignore2
-rw-r--r--Dockerfile11
-rw-r--r--gitignore.go22
-rw-r--r--go.mod10
-rw-r--r--go.sum16
-rw-r--r--main.go37
-rw-r--r--sync.go106
-rw-r--r--watcher.go210
8 files changed, 414 insertions, 0 deletions
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
+ })
+}