aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 22:16:37 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 22:16:37 +0200
commit913a974eca2522ba7c6d27066481bb2c6635db53 (patch)
treef0cfc1ee173a762662b586fca5db9db422592093
parent5f2ba9c3d37a1c8658aea411188a6763e982b1ab (diff)
downloadsourcewatch-913a974eca2522ba7c6d27066481bb2c6635db53.tar.gz
sourcewatch-913a974eca2522ba7c6d27066481bb2c6635db53.zip
Add log level system and sync logging
- log.go: log level system (DEBUG, INFO, WARN, ERROR, NONE) - CLI: -log-level flag (default info), -verbose as shorthand for debug - Watcher: logs sync/remove actions at INFO level - Watcher: logs events at DEBUG level - Errors: structured error messages (DISK FULL, PERMISSION DENIED, etc.) - Updated README with -log-level flag
-rw-r--r--README.md3
-rw-r--r--errors.go11
-rw-r--r--errors_test.go8
-rw-r--r--log.go72
-rw-r--r--main.go28
-rw-r--r--sync.go17
-rw-r--r--watcher.go61
-rw-r--r--watcher_test.go32
8 files changed, 161 insertions, 71 deletions
diff --git a/README.md b/README.md
index 2046948..46faabc 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,8 @@ podman run -v c:\source:/host:ro -v watchdata:/data sourcewatch
```
-host string Host source directory (default "/host")
-data string Data destination directory (default "/data")
--verbose Enable verbose logging
+-verbose Enable verbose logging (shorthand for -log-level debug)
+-log-level string Log level: debug, info, warn, error, none (default "info")
-dry-run Preview what would be synced without copying
```
diff --git a/errors.go b/errors.go
index 282a08b..2cc0b4b 100644
--- a/errors.go
+++ b/errors.go
@@ -3,7 +3,6 @@ package main
import (
"errors"
"io/fs"
- "log"
"os"
"syscall"
"time"
@@ -51,7 +50,7 @@ func isRetryable(err error) bool {
return false
}
-func logError(op, relPath string, err error) {
+func logSyncError(op, relPath string, err error) {
if err == nil {
return
}
@@ -65,13 +64,13 @@ func logError(op, relPath string, err error) {
switch {
case errors.Is(err, syscall.ENOSPC):
- log.Printf("DISK FULL: %s %s failed - no space left on device", op, relPath)
+ logErrorf("DISK FULL: %s %s failed - no space left on device", op, relPath)
case errors.Is(err, syscall.EACCES), errors.Is(err, syscall.EPERM):
- log.Printf("PERMISSION DENIED: %s %s - check volume mount permissions", op, relPath)
+ logErrorf("PERMISSION DENIED: %s %s - check volume mount permissions", op, relPath)
case errors.Is(err, syscall.ELOOP):
- log.Printf("SYMLINK LOOP: %s %s - too many symbolic links", op, relPath)
+ logErrorf("SYMLINK LOOP: %s %s - too many symbolic links", op, relPath)
default:
- log.Printf("ERROR: %s %s: %v", op, relPath, err)
+ logErrorf("%s %s: %v", op, relPath, err)
}
}
diff --git a/errors_test.go b/errors_test.go
index 553939d..e35e5b3 100644
--- a/errors_test.go
+++ b/errors_test.go
@@ -101,19 +101,19 @@ func TestIsRetryable(t *testing.T) {
}
func TestLogError_DiskFull(t *testing.T) {
- logError("copy", "test.txt", syscall.ENOSPC)
+ logSyncError("copy", "test.txt", syscall.ENOSPC)
}
func TestLogError_PermissionDenied(t *testing.T) {
- logError("write", "test.txt", syscall.EACCES)
+ logSyncError("write", "test.txt", syscall.EACCES)
}
func TestLogError_NotExist(t *testing.T) {
- logError("stat", "test.txt", fs.ErrNotExist)
+ logSyncError("stat", "test.txt", fs.ErrNotExist)
}
func TestLogError_Nil(t *testing.T) {
- logError("test", "file.txt", nil)
+ logSyncError("test", "file.txt", nil)
}
func TestSyncError(t *testing.T) {
diff --git a/log.go b/log.go
new file mode 100644
index 0000000..7d5fa1b
--- /dev/null
+++ b/log.go
@@ -0,0 +1,72 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+type LogLevel int
+
+const (
+ LevelDebug LogLevel = iota
+ LevelInfo
+ LevelWarn
+ LevelError
+ LevelNone
+)
+
+var logLevelNames = map[LogLevel]string{
+ LevelDebug: "DEBUG",
+ LevelInfo: "INFO",
+ LevelWarn: "WARN",
+ LevelError: "ERROR",
+ LevelNone: "NONE",
+}
+
+var currentLevel = LevelInfo
+
+func SetLogLevel(level LogLevel) {
+ currentLevel = level
+}
+
+func ParseLogLevel(s string) (LogLevel, error) {
+ switch strings.ToUpper(s) {
+ case "DEBUG":
+ return LevelDebug, nil
+ case "INFO":
+ return LevelInfo, nil
+ case "WARN":
+ return LevelWarn, nil
+ case "ERROR":
+ return LevelError, nil
+ case "NONE":
+ return LevelNone, nil
+ default:
+ return LevelInfo, fmt.Errorf("unknown log level: %s (valid: debug, info, warn, error, none)", s)
+ }
+}
+
+func logDebug(format string, args ...interface{}) {
+ if currentLevel <= LevelDebug {
+ fmt.Fprintf(os.Stderr, "DEBUG: "+format+"\n", args...)
+ }
+}
+
+func logInfo(format string, args ...interface{}) {
+ if currentLevel <= LevelInfo {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ }
+}
+
+func logWarn(format string, args ...interface{}) {
+ if currentLevel <= LevelWarn {
+ fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", args...)
+ }
+}
+
+func logErrorf(format string, args ...interface{}) {
+ if currentLevel <= LevelError {
+ fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...)
+ }
+}
diff --git a/main.go b/main.go
index 077aec2..570890d 100644
--- a/main.go
+++ b/main.go
@@ -12,37 +12,47 @@ import (
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")
+ verbose = flag.Bool("verbose", false, "Enable verbose logging (shorthand for -log-level debug)")
+ logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, error, none")
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)
+ *logLevel = "debug"
}
+ level, err := ParseLogLevel(*logLevel)
+ if err != nil {
+ log.Fatal(err)
+ }
+ SetLogLevel(level)
+
+ log.SetFlags(0)
+ log.SetOutput(nil)
+
stack := NewGitignoreStack(*hostDir)
manifest, err := initialSync(*hostDir, *dataDir, stack, *dryRun)
if err != nil {
- log.Fatalf("initial sync failed: %v", err)
+ logErrorf("initial sync failed: %v", err)
+ os.Exit(1)
}
if *dryRun {
- log.Println("dry-run complete, exiting")
+ logInfo("dry-run complete, exiting")
return
}
- log.Println("watcher: ready, monitoring for changes ...")
+ logInfo("watcher: ready, monitoring for changes ...")
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
- if err := watch(ctx, *hostDir, *dataDir, manifest, stack, *dryRun, *verbose); err != nil {
- log.Fatalf("watcher failed: %v", err)
+ if err := watch(ctx, *hostDir, *dataDir, manifest, stack, *dryRun); err != nil {
+ logErrorf("watcher failed: %v", err)
+ os.Exit(1)
}
}
diff --git a/sync.go b/sync.go
index 2cead22..806b7a0 100644
--- a/sync.go
+++ b/sync.go
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"io"
- "log"
"os"
"path/filepath"
"time"
@@ -32,12 +31,12 @@ func initialSync(hostDir, dataDir string, stack *GitignoreStack, dryRun bool) (*
stats := &syncStats{}
start := time.Now()
- log.Printf("initial sync: scanning %s ...", hostDir)
+ logInfo("initial sync: scanning %s ...", hostDir)
err := filepath.Walk(hostDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
stats.errors++
- logError("walk", path, err)
+ logErrorf("walk %s: %v", path, err)
return nil
}
@@ -86,7 +85,7 @@ func initialSync(hostDir, dataDir string, stack *GitignoreStack, dryRun bool) (*
linkTarget, err = os.Readlink(path)
if err != nil {
stats.errors++
- logError("readlink", relPath, err)
+ logErrorf("readlink %s: %v", relPath, err)
return nil
}
}
@@ -106,35 +105,35 @@ func initialSync(hostDir, dataDir string, stack *GitignoreStack, dryRun bool) (*
if info.IsDir() {
if err := os.MkdirAll(destPath, info.Mode()); err != nil {
stats.errors++
- logError("mkdir", relPath, err)
+ logErrorf("mkdir %s: %v", relPath, err)
}
return nil
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
stats.errors++
- logError("mkdir", filepath.Dir(relPath), err)
+ logErrorf("mkdir %s: %v", filepath.Dir(relPath), err)
return nil
}
if linkTarget != "" {
if err := os.Symlink(linkTarget, destPath); err != nil {
stats.errors++
- logError("symlink", relPath, err)
+ logErrorf("symlink %s: %v", relPath, err)
}
return nil
}
if err := copyFileWithRetry(path, destPath, info.Mode()); err != nil {
stats.errors++
- logError("copy", relPath, err)
+ logErrorf("copy %s: %v", relPath, err)
}
return nil
})
elapsed := time.Since(start)
fmt.Fprintf(os.Stderr, "\r")
- log.Printf("initial sync: done in %s — %d files, %d dirs, %d skipped, %d errors",
+ logInfo("initial sync: done in %s — %d files, %d dirs, %d skipped, %d errors",
elapsed.Round(time.Millisecond), stats.files, stats.dirs, stats.skipped, stats.errors)
return manifest, err
diff --git a/watcher.go b/watcher.go
index 7df290d..8c8177e 100644
--- a/watcher.go
+++ b/watcher.go
@@ -2,7 +2,6 @@ package main
import (
"context"
- "log"
"os"
"path/filepath"
"sync"
@@ -11,7 +10,7 @@ import (
"github.com/fsnotify/fsnotify"
)
-func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, stack *GitignoreStack, dryRun, verbose bool) error {
+func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, stack *GitignoreStack, dryRun bool) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
@@ -48,9 +47,7 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, sta
continue
}
- if verbose {
- log.Printf("event: %s %s", event.Op, relPath)
- }
+ logDebug("event: %s %s", event.Op, relPath)
mu.Lock()
if t, exists := debounce[relPath]; exists {
@@ -60,7 +57,7 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, sta
mu.Lock()
delete(debounce, relPath)
mu.Unlock()
- handleEvent(event.Op, hostDir, dataDir, relPath, manifest, stack, dryRun, verbose, watcher)
+ handleEvent(event.Op, hostDir, dataDir, relPath, manifest, stack, dryRun, watcher)
})
mu.Unlock()
@@ -68,37 +65,37 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, sta
if !ok {
return nil
}
- log.Printf("watcher error: %v", err)
+ logErrorf("watcher: %v", err)
}
}
}
-func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun, verbose bool, watcher *fsnotify.Watcher) {
+func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun 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, stack, dryRun, verbose, watcher)
+ syncCreate(srcPath, destPath, relPath, manifest, stack, dryRun, watcher)
case op&fsnotify.Write != 0:
- syncWrite(srcPath, destPath, relPath, manifest, dryRun, verbose)
+ syncWrite(srcPath, destPath, relPath, manifest, dryRun)
case op&fsnotify.Remove != 0:
- syncRemove(destPath, relPath, manifest, dryRun, verbose)
+ syncRemove(destPath, relPath, manifest, dryRun)
case op&fsnotify.Rename != 0:
- syncRemove(destPath, relPath, manifest, dryRun, verbose)
+ syncRemove(destPath, relPath, manifest, dryRun)
}
}
-func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun, verbose bool, watcher *fsnotify.Watcher) {
+func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun bool, watcher *fsnotify.Watcher) {
info, err := os.Lstat(srcPath)
if err != nil {
if os.IsNotExist(err) {
return
}
- logError("stat", relPath, err)
+ logErrorf("stat %s: %v", relPath, err)
return
}
@@ -107,13 +104,14 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
stack.Push(srcPath)
}
if err := watcher.Add(srcPath); err != nil {
- logError("watch", relPath, err)
+ logErrorf("watch %s: %v", relPath, err)
}
if dryRun {
+ logInfo("[dry-run] would create dir: %s", relPath)
return
}
if err := os.MkdirAll(destPath, info.Mode()); err != nil {
- logError("mkdir", relPath, err)
+ logErrorf("mkdir %s: %v", relPath, err)
}
return
}
@@ -122,7 +120,7 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(srcPath)
if err != nil {
- logError("readlink", relPath, err)
+ logErrorf("readlink %s: %v", relPath, err)
return
}
}
@@ -136,6 +134,7 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
manifest.Files[relPath] = meta
if dryRun {
+ logInfo("[dry-run] would sync: %s", relPath)
return
}
@@ -144,23 +143,26 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *Gi
if linkTarget != "" {
if err := os.Symlink(linkTarget, destPath); err != nil {
- logError("symlink", relPath, err)
+ logErrorf("symlink %s: %v", relPath, err)
}
+ logInfo("sync: %s -> %s (symlink)", relPath, linkTarget)
return
}
if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil {
- logError("copy", relPath, err)
+ logErrorf("copy %s: %v", relPath, err)
+ } else {
+ logInfo("sync: %s", relPath)
}
}
-func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, verbose bool) {
+func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun bool) {
info, err := os.Lstat(srcPath)
if err != nil {
if os.IsNotExist(err) {
return
}
- logError("stat", relPath, err)
+ logErrorf("stat %s: %v", relPath, err)
return
}
@@ -173,7 +175,7 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(srcPath)
if err != nil {
- logError("readlink", relPath, err)
+ logErrorf("readlink %s: %v", relPath, err)
return
}
}
@@ -186,6 +188,7 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
}
if dryRun {
+ logInfo("[dry-run] would update: %s", relPath)
return
}
@@ -193,25 +196,31 @@ func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, ve
if linkTarget != "" {
if err := os.Symlink(linkTarget, destPath); err != nil {
- logError("symlink", relPath, err)
+ logErrorf("symlink %s: %v", relPath, err)
}
+ logInfo("sync: %s -> %s (symlink)", relPath, linkTarget)
return
}
if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil {
- logError("copy", relPath, err)
+ logErrorf("copy %s: %v", relPath, err)
+ } else {
+ logInfo("sync: %s", relPath)
}
}
-func syncRemove(destPath, relPath string, manifest *Manifest, dryRun, verbose bool) {
+func syncRemove(destPath, relPath string, manifest *Manifest, dryRun bool) {
delete(manifest.Files, relPath)
if dryRun {
+ logInfo("[dry-run] would remove: %s", relPath)
return
}
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
- logError("remove", relPath, err)
+ logErrorf("remove %s: %v", relPath, err)
+ } else {
+ logInfo("remove: %s", relPath)
}
}
diff --git a/watcher_test.go b/watcher_test.go
index 8fac419..df16930 100644
--- a/watcher_test.go
+++ b/watcher_test.go
@@ -20,7 +20,7 @@ func TestWatcher_Create(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -42,7 +42,7 @@ func TestWatcher_CreateInSubdir(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -66,7 +66,7 @@ func TestWatcher_Write(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -90,7 +90,7 @@ func TestWatcher_Remove(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -114,7 +114,7 @@ func TestWatcher_Rename(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -139,7 +139,7 @@ func TestWatcher_IgnoredFileEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -167,7 +167,7 @@ func TestWatcher_Debouncing(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -199,7 +199,7 @@ func TestWatcher_CreateDirectory(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -229,7 +229,7 @@ func TestWatcher_RemoveDirectory(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -255,7 +255,7 @@ func TestWatcher_VerifyManifestUpdated(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -282,7 +282,7 @@ func TestWatcher_NestedGitignore_CreateIgnored(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -310,7 +310,7 @@ func TestWatcher_NestedGitignore_CreateUnignore(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -332,7 +332,7 @@ func TestWatcher_CreateSymlink(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -382,7 +382,7 @@ func TestWatcher_UpdateSymlinkTarget(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -421,7 +421,7 @@ func TestWatcher_RemoveSymlink(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)
@@ -447,7 +447,7 @@ func TestWatcher_ModifySymlinkTargetFile(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- go watch(ctx, host, data, manifest, stack, false, false)
+ go watch(ctx, host, data, manifest, stack, false)
time.Sleep(100 * time.Millisecond)