From 366152c483bf062d6c7f513f023eac971bd93934 Mon Sep 17 00:00:00 2001 From: Christian Schmied Date: Thu, 26 Sep 2024 12:34:36 +0200 Subject: [PATCH] init --- .gitignore | 145 +++++++++++++ Jenkinsfile | 56 +++++ config.go | 6 + folderwatcher.go | 156 ++++++++++++++ folderwatcher_test.go | 475 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 10 + go.sum | 18 ++ 7 files changed, 866 insertions(+) create mode 100644 .gitignore create mode 100644 Jenkinsfile create mode 100644 config.go create mode 100644 folderwatcher.go create mode 100644 folderwatcher_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..067758e --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,goland+all,linux,git +# Edit at https://www.toptal.com/developers/gitignore?templates=go,goland+all,linux,git + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### GoLand+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# End of https://www.toptal.com/developers/gitignore/api/go,goland+all,linux,git diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..235f93e --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,56 @@ +pipeline { + environment { + ORG = "go" + APP = "folderwatcher" + GIT_REPO_OWNER = "go" + GIT_REPO_NAME = "folderwatcher" + } + + agent { + docker { + image 'docker.sportwanninger.de/infrastructure/golang-mingw:1.23.1' + args "${dindArgs()}" + } + } + + options { + buildDiscarder(logRotator(numToKeepStr: '5', artifactNumToKeepStr: '5')) + disableConcurrentBuilds() + } + + stages { + stage('Build') { + environment { + CGO_ENABLED = "1" + GOOS = "linux" + CXX = "g++" + CC = "gcc" + } + steps { + ansiColor('xterm') { + sh "go test ./..." + } + } + } + stage('Build Win') { + environment { + CGO_ENABLED = "1" + GOOS = "windows" + CXX = "x86_64-w64-mingw32-g++" + CC = "x86_64-w64-mingw32-gcc" + } + steps { + ansiColor('xterm') { + sh "go test -exec wine64 -ldflags \"-extldflags '-static -fstack-protector'\" ./..." + } + } + } + } + post { + always { + script { + sendBuildMessage(); + } + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..f2510de --- /dev/null +++ b/config.go @@ -0,0 +1,6 @@ +package folderwatcher + +type Config struct { + Folder string + Pattern string +} diff --git a/folderwatcher.go b/folderwatcher.go new file mode 100644 index 0000000..596ff7e --- /dev/null +++ b/folderwatcher.go @@ -0,0 +1,156 @@ +package folderwatcher + +import ( + "github.com/sirupsen/logrus" + "os" + "path/filepath" + "regexp" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +var logger = logrus.WithField("package", "folderwatcher") + +const DebounceTime = 2 * time.Second + +type FolderWatcher struct { + watcher *fsnotify.Watcher + Config Config + regex *regexp.Regexp + quit chan struct{} + mutex sync.Mutex + wg sync.WaitGroup + debounceMap map[string]*time.Timer + debouncedChan chan string + enqueuedFiles int +} + +func NewFolderWatcher(conf Config, includeExisting bool, quit chan struct{}) (*FolderWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + matches, err := filepath.Glob(conf.Folder) + if err != nil { + return nil, err + } + + var matchedFolders []string + for _, path := range matches { + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + if stat.IsDir() { + matchedFolders = append(matchedFolders, path) + } + } + + for _, path := range matchedFolders { + err = watcher.Add(path) + if err != nil { + return nil, err + } + } + + var regex *regexp.Regexp + if len(conf.Pattern) > 0 { + regex, err = regexp.Compile("^" + conf.Pattern + "$") + if err != nil { + return nil, err + } + } + + var folderWatcher = &FolderWatcher{ + watcher: watcher, + Config: conf, + regex: regex, + quit: quit, + debounceMap: make(map[string]*time.Timer), + debouncedChan: make(chan string), + } + + if includeExisting { + for _, path := range matchedFolders { + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + for _, entry := range entries { + if !entry.IsDir() { + folderWatcher.foundFileEvent(filepath.Join(path, entry.Name())) + } + } + } + } + + return folderWatcher, nil +} + +type FileHandler func(path string) (triggerCallback bool, err error) +type HandlerCallback func(path string, err error) + +func (w *FolderWatcher) Watch(handler FileHandler, callback HandlerCallback) { + defer w.watcher.Close() + for stopped := false; !stopped; { + select { + case <-w.quit: + logger.Info("Stopping watcher") + stopped = true + case event, ok := <-w.watcher.Events: + if !ok { + stopped = true + break + } + logger.Debug("event:", event) + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + w.foundFileEvent(event.Name) + } + case file, _ := <-w.debouncedChan: + triggerCallback, err := handler(file) + if err != nil { + logger.Warn("error during handle:", err) + } + if triggerCallback { + callback(file, err) + } + w.wg.Done() + case err, ok := <-w.watcher.Errors: + if !ok { + stopped = true + break + } + logger.Warn("error:", err) + } + } + w.wg.Wait() + logger.Infof("watcher handled %d files", w.enqueuedFiles) +} + +func (w *FolderWatcher) foundFileEvent(fileName string) { + baseName := filepath.Base(fileName) + if w.regex == nil || w.regex.MatchString(baseName) { + w.enqueueDebounce(fileName) + } +} + +func (w *FolderWatcher) enqueueDebounce(f string) { + w.mutex.Lock() + defer w.mutex.Unlock() + + if timer, ok := w.debounceMap[f]; ok && timer != nil { + timer.Stop() + } else { + w.wg.Add(1) + } + w.debounceMap[f] = time.AfterFunc(DebounceTime, func() { + w.mutex.Lock() + defer w.mutex.Unlock() + delete(w.debounceMap, f) + w.enqueuedFiles = w.enqueuedFiles + 1 + w.debouncedChan <- f + }) +} diff --git a/folderwatcher_test.go b/folderwatcher_test.go new file mode 100644 index 0000000..ab842ed --- /dev/null +++ b/folderwatcher_test.go @@ -0,0 +1,475 @@ +package folderwatcher + +import ( + "context" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" +) + +func TestNewFileHandling(t *testing.T) { + tmpPath, err := os.MkdirTemp("", ".folderwatcher_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpPath) + quitChan := make(chan struct{}) + + filesIn := []string{"A", "B", "C"} + for i, f := range filesIn { //remap filesIn to Full Path + filesIn[i] = filepath.Join(tmpPath, f) + if !filepath.IsAbs(filesIn[i]) { + t.Fatalf("file %s is not an absolute path", filesIn[i]) + } + } + var filesOut []string + + wg := sync.WaitGroup{} + + conf := Config{ + Folder: tmpPath, + } + watcher, err := NewFolderWatcher(conf, true, quitChan) + if err != nil { + t.Fatal(err) + } + + go watcher.Watch(func(filePath string) (bool, error) { + if !filepath.IsAbs(filePath) { + t.Errorf("file %s is not an absolute path", filePath) + } + filesOut = append(filesOut, filePath) + wg.Done() + return true, nil + }, func(s string, err error) { + }) + + for _, f := range filesIn { + wg.Add(1) + _ = os.WriteFile(f, []byte{0}, os.ModePerm) + } + + finishedChan := make(chan struct{}) + go func() { + wg.Wait() + finishedChan <- struct{}{} + }() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + select { + case <-finishedChan: + case <-ctx.Done(): + t.Error(ctx.Err()) + } + quitChan <- struct{}{} + + if len(filesOut) != len(filesIn) { + t.Errorf("filesOut length %d != %d", len(filesOut), len(filesIn)) + } + for _, f := range filesIn { + if !Contains(filesOut, f) { + t.Errorf("File %s not found in %s", f, filesOut) + } + } +} + +func TestOldFileHandling(t *testing.T) { + tmpPath, err := os.MkdirTemp("", ".folderwatcher_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpPath) + quitChan := make(chan struct{}) + + filesIn := []string{"A", "B", "C"} + for i, f := range filesIn { //remap filesIn to Full Path + filesIn[i] = filepath.Join(tmpPath, f) + if !filepath.IsAbs(filesIn[i]) { + t.Fatalf("file %s is not an absolute path", filesIn[i]) + } + } + var filesOut []string + + wg := sync.WaitGroup{} + + conf := Config{ + Folder: tmpPath, + } + + for _, f := range filesIn { + wg.Add(1) + _ = os.WriteFile(f, []byte{0}, os.ModePerm) + } + + watcher, err := NewFolderWatcher(conf, true, quitChan) + if err != nil { + t.Fatal(err) + } + + go watcher.Watch(func(filePath string) (bool, error) { + if !filepath.IsAbs(filePath) { + t.Errorf("file %s is not an absolute path", filePath) + } + filesOut = append(filesOut, filePath) + wg.Done() + return true, nil + }, func(s string, err error) { + }) + + finishedChan := make(chan struct{}) + go func() { + wg.Wait() + finishedChan <- struct{}{} + }() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + select { + case <-finishedChan: + case <-ctx.Done(): + t.Error(ctx.Err()) + } + quitChan <- struct{}{} + + if len(filesOut) != len(filesIn) { + t.Errorf("filesOut length %d != %d", len(filesOut), len(filesIn)) + } + for _, f := range filesIn { + if !Contains(filesOut, f) { + t.Errorf("File %s not found in %s", f, filesOut) + } + } +} + +func TestOldAndNewFileHandling(t *testing.T) { + tmpPath, err := os.MkdirTemp("", ".folderwatcher_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpPath) + quitChan := make(chan struct{}) + + filesIn := []string{"A", "B", "C"} + for i, f := range filesIn { //remap filesIn to Full Path + filesIn[i] = filepath.Join(tmpPath, f) + if !filepath.IsAbs(filesIn[i]) { + t.Fatalf("file %s is not an absolute path", filesIn[i]) + } + } + filesInRunning := []string{"D", "E", "F"} + for i, f := range filesInRunning { //remap filesIn to Full Path + filesInRunning[i] = filepath.Join(tmpPath, f) + if !filepath.IsAbs(filesInRunning[i]) { + t.Fatalf("file %s is not an absolute path", filesInRunning[i]) + } + } + var filesOut []string + + wg := sync.WaitGroup{} + + conf := Config{ + Folder: tmpPath, + } + + for _, f := range filesIn { + wg.Add(1) + _ = os.WriteFile(f, []byte{0}, os.ModePerm) + } + + watcher, err := NewFolderWatcher(conf, true, quitChan) + if err != nil { + t.Fatal(err) + } + + go watcher.Watch(func(filePath string) (bool, error) { + if !filepath.IsAbs(filePath) { + t.Errorf("file %s is not an absolute path", filePath) + } + filesOut = append(filesOut, filePath) + wg.Done() + return true, nil + }, func(s string, err error) { + }) + + for _, f := range filesInRunning { + wg.Add(1) + filesIn = append(filesIn, f) + _ = os.WriteFile(f, []byte{0}, os.ModePerm) + } + + finishedChan := make(chan struct{}) + go func() { + wg.Wait() + finishedChan <- struct{}{} + }() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + select { + case <-finishedChan: + case <-ctx.Done(): + t.Error(ctx.Err()) + } + quitChan <- struct{}{} + + if len(filesOut) != len(filesIn) { + t.Errorf("filesOut length %d != %d", len(filesOut), len(filesIn)) + } + for _, f := range filesIn { + if !Contains(filesOut, f) { + t.Errorf("File %s not found in %s", f, filesOut) + } + } +} + +func TestOldAndNewFileHandlingWithoutExisting(t *testing.T) { + tmpPath, err := os.MkdirTemp("", ".folderwatcher_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpPath) + quitChan := make(chan struct{}) + + filesIn := []string{"A", "B", "C"} + for i, f := range filesIn { //remap filesIn to Full Path + filesIn[i] = filepath.Join(tmpPath, f) + if !filepath.IsAbs(filesIn[i]) { + t.Fatalf("file %s is not an absolute path", filesIn[i]) + } + } + filesInRunning := []string{"D", "E", "F"} + for i, f := range filesInRunning { //remap filesInRunning to Full Path + filesInRunning[i] = filepath.Join(tmpPath, f) + if !filepath.IsAbs(filesInRunning[i]) { + t.Fatalf("file %s is not an absolute path", filesInRunning[i]) + } + } + var filesOut []string + + wg := sync.WaitGroup{} + + conf := Config{ + Folder: tmpPath, + } + + for _, f := range filesIn { + _ = os.WriteFile(f, []byte{0}, os.ModePerm) + } + + watcher, err := NewFolderWatcher(conf, false, quitChan) + if err != nil { + t.Fatal(err) + } + + go watcher.Watch(func(filePath string) (bool, error) { + if !filepath.IsAbs(filePath) { + t.Errorf("file %s is not an absolute path", filePath) + } + filesOut = append(filesOut, filePath) + wg.Done() + return true, nil + }, func(s string, err error) { + }) + + for _, f := range filesInRunning { + wg.Add(1) + _ = os.WriteFile(f, []byte{0}, os.ModePerm) + } + + finishedChan := make(chan struct{}) + go func() { + wg.Wait() + finishedChan <- struct{}{} + }() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + select { + case <-finishedChan: + case <-ctx.Done(): + t.Error(ctx.Err()) + } + quitChan <- struct{}{} + + if len(filesOut) != len(filesInRunning) { + t.Errorf("filesOut length %d != %d", len(filesOut), len(filesInRunning)) + } + for _, f := range filesInRunning { + if !Contains(filesOut, f) { + t.Errorf("File %s not found in %s", f, filesOut) + } + } + for _, f := range filesIn { + if Contains(filesOut, f) { + t.Errorf("File %s found in %s", f, filesOut) + } + } +} + +func TestGlobbingN1(t *testing.T) { + tmpPath, err := os.MkdirTemp("", ".folderwatcher_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpPath) + quitChan := make(chan struct{}) + + _ = os.MkdirAll(filepath.Join(tmpPath, "g0"), os.ModePerm) + _ = os.MkdirAll(filepath.Join(tmpPath, "g1"), os.ModePerm) + _ = os.MkdirAll(filepath.Join(tmpPath, "g2"), os.ModePerm) + + filesIn := []string{"A", "B", "C"} + var filesOut []string + + wg := sync.WaitGroup{} + + conf := Config{ + Folder: filepath.Join(tmpPath, "*"), + } + + watcher, err := NewFolderWatcher(conf, true, quitChan) + if err != nil { + t.Fatal(err) + } + + go watcher.Watch(func(filePath string) (bool, error) { + if !filepath.IsAbs(filePath) { + t.Errorf("file %s is not an absolute path", filePath) + } + rel, err := filepath.Rel(tmpPath, filePath) + if err != nil { + return true, err + } + filesOut = append(filesOut, rel) + wg.Done() + return true, nil + }, func(s string, err error) { + }) + + for i, f := range filesIn { + wg.Add(1) + _ = os.WriteFile(filepath.Join(tmpPath, "g"+strconv.Itoa(i), f), []byte{0}, os.ModePerm) + } + + finishedChan := make(chan struct{}) + go func() { + wg.Wait() + finishedChan <- struct{}{} + }() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + select { + case <-finishedChan: + case <-ctx.Done(): + t.Error(ctx.Err()) + } + quitChan <- struct{}{} + + if len(filesOut) != len(filesIn) { + t.Errorf("filesOut length %d != %d", len(filesOut), len(filesIn)) + } + for i, f := range filesIn { + if !Contains(filesOut, filepath.Join("g"+strconv.Itoa(i), f)) { + t.Errorf("File %s not found in %s", f, filesOut) + } + } +} + +func TestGlobbingN2(t *testing.T) { + tmpPath, err := os.MkdirTemp("", ".folderwatcher_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpPath) + quitChan := make(chan struct{}) + + _ = os.MkdirAll(filepath.Join(tmpPath, "g0", "s0"), os.ModePerm) + _ = os.MkdirAll(filepath.Join(tmpPath, "g0", "s2"), os.ModePerm) + _ = os.MkdirAll(filepath.Join(tmpPath, "g1", "s1"), os.ModePerm) + _ = os.MkdirAll(filepath.Join(tmpPath, "g1", "s3"), os.ModePerm) + _ = os.MkdirAll(filepath.Join(tmpPath, "gX"), os.ModePerm) + + filesIn := []string{"A", "B", "C", "D"} + var filesOut []string + + wg := sync.WaitGroup{} + + conf := Config{ + Folder: filepath.Join(tmpPath, "*", "*"), + } + + watcher, err := NewFolderWatcher(conf, true, quitChan) + if err != nil { + t.Fatal(err) + } + + go watcher.Watch(func(filePath string) (bool, error) { + if !filepath.IsAbs(filePath) { + t.Errorf("file %s is not an absolute path", filePath) + } + rel, err := filepath.Rel(tmpPath, filePath) + if err != nil { + return true, err + } + filesOut = append(filesOut, rel) + wg.Done() + return true, nil + }, func(s string, err error) { + }) + + for i, f := range filesIn { + wg.Add(1) + _ = os.WriteFile(filepath.Join(tmpPath, "g"+strconv.Itoa(i%2), "s"+strconv.Itoa(i), f), []byte{0}, os.ModePerm) + } + + wg.Add(1) + _ = os.WriteFile(filepath.Join(tmpPath, "gX", "foo"), []byte{0}, os.ModePerm) + + finishedChan := make(chan struct{}) + go func() { + wg.Wait() + finishedChan <- struct{}{} + }() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer ctxCancel() + + select { + case <-finishedChan: + case <-ctx.Done(): + wg.Done() // ctx Will Timeout, because gX/foo will always be remaining + // t.Error(ctx.Err()) + } + quitChan <- struct{}{} + + if len(filesOut) != len(filesIn) { + t.Errorf("filesOut length %d != %d", len(filesOut), len(filesIn)) + } + for i, f := range filesIn { + if !Contains(filesOut, filepath.Join("g"+strconv.Itoa(i%2), "s"+strconv.Itoa(i), f)) { + t.Errorf("File %s not found in %s", f, filesOut) + } + } + if Contains(filesOut, filepath.Join("gX", "foo")) { + t.Errorf("File %s found in %s", filepath.Join("gX", "foo"), filesOut) + } +} + +func Contains[T comparable](slice []T, item T) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ad4eda --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.sportwanninger.de/go/folderwatcher + +go 1.23.1 + +require ( + github.com/fsnotify/fsnotify v1.7.0 + github.com/sirupsen/logrus v1.9.3 +) + +require golang.org/x/sys v0.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f2cbafb --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.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=