diff options
| author | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:15:17 +0200 |
|---|---|---|
| committer | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:15:17 +0200 |
| commit | b505ac2fe010fcc24b774c313d33dc099214e8ed (patch) | |
| tree | ff3f6472c84abd45e7918722e70ee681b859b780 | |
| download | sourcewatch-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-- | .gitignore | 2 | ||||
| -rw-r--r-- | Dockerfile | 11 | ||||
| -rw-r--r-- | gitignore.go | 22 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 16 | ||||
| -rw-r--r-- | main.go | 37 | ||||
| -rw-r--r-- | sync.go | 106 | ||||
| -rw-r--r-- | watcher.go | 210 |
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 +} @@ -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 @@ -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= @@ -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) + } +} @@ -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 + }) +} |
