initial commit
20 files changed, 2222 insertions(+), 0 deletions(-)
A .gitignore
@@ -0,0 +1,32 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go +# Edit at https://www.toptal.com/developers/gitignore?templates=go + +### 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 + +# End of https://www.toptal.com/developers/gitignore/api/go +erl +coverage.out +.envrc +/.pre-commit-config.yaml +/result
A .golangci.yaml
@@ -0,0 +1,17 @@ +--- +# yamllint disable-line rule:line-length +# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json +version: "2" +linters: + disable: + - errcheck + enable: + - funcorder + - gocritic + - govet + - lll + - nilerr + - nlreturn + - paralleltest + - usestdlibvars + - whitespace
A LICENSE
@@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Alan Pearce + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
A README.md
@@ -0,0 +1,65 @@ +# E(a)rl + +E(a)rl helps with your development by restarting your command when files change. + +A file watcher based on things I liked about [nodemon](https://github.com/remy/nodemon) and [watchexec](https://github.com/watchexec/watchexec): + +- When command exits, wait for changes instead of re-starting ad infinitum + +The name is an acronym for "Execute Reload Loop", inspired by "Read Eval Print Loop". "Earl" is nicer to say than "erl", but "erl" is nicer to type on a QWERTY keyboard. + +## Installation + +### Using Nix + +If you have Nix with flakes enabled: + +```bash +nix run git+https:git.alin.ovh/erl -- -exec your-command arg1 arg2 +``` + +Or install it: + +```bash +nix profile install git+https:git.alin.ovh/erl +``` + +## Usage + +Basic usage: + +```bash +erl -exec go run main.go +``` + +### Command Line Options + +- `-exec string` - Command to execute on file change (required) + +### Examples + +**Go development:** +```bash +erl -exec go run . +``` + +**Build and test:** +```bash +erl -exec make test +``` + +## Development + +```bash +nix develop + +just run + +just test + +just build +``` + +## License + +This project is available under the MIT License.
A command/command.go
@@ -0,0 +1,107 @@ +package command + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/fatih/color" +) + +type Command interface { + Start() error + Wait() error + Stop() error +} + +type Cmd struct { + *exec.Cmd + + name string + args []string + + opts Options + + printFail func(string, ...any) + printSuccess func(string, ...any) +} + +type Options struct { + Stdout io.Writer + Stderr io.Writer +} + +func New(name string, args []string, options Options) *Cmd { + if options.Stdout == nil { + options.Stdout = os.Stdout + } + if options.Stderr == nil { + options.Stderr = os.Stderr + } + + return &Cmd{ + name: name, + args: args, + opts: options, + + printFail: color.New(color.FgRed).PrintfFunc(), + printSuccess: color.New(color.FgGreen).PrintfFunc(), + } +} + +func (cmd *Cmd) Stop() error { + if cmd.Cmd != nil && cmd.Process != nil && cmd.ProcessState == nil { + err := cmd.Process.Kill() + if err != nil { + return fmt.Errorf("error killing command: %v", err) + } + } + + return nil +} + +func (cmd *Cmd) Exited() bool { + return cmd.Cmd != nil && cmd.ProcessState != nil && cmd.ProcessState.Exited() +} + +func (cmd *Cmd) Wait() error { + err := cmd.Cmd.Wait() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if cmd.Exited() { + cmd.printFail("[command exited with code %d]\n", cmd.ProcessState.ExitCode()) + } else { + cmd.printFail("[command stopped]\n") + } + } else { + return fmt.Errorf("error waiting for command: %v", err) + } + } + + if cmd.Exited() && cmd.ProcessState.Success() { + cmd.printSuccess("[command finished]\n") + } + + return nil +} + +func (cmd *Cmd) Start() error { + cmd.makeCommand() + cmd.printSuccess("[running: %s %s]\n", cmd.name, strings.Join(cmd.args, " ")) + err := cmd.Cmd.Start() + if err != nil { + return fmt.Errorf("error starting command: %v", err) + } + + return err +} + +func (cmd *Cmd) makeCommand() { + cmd.Cmd = exec.Command(cmd.name, cmd.args...) + cmd.Stdout = cmd.opts.Stdout + cmd.Stderr = cmd.opts.Stderr +}
A command/command_test.go
@@ -0,0 +1,431 @@ +package command + +import ( + "bytes" + "testing" + "time" +) + +func TestNew(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmdName string + args []string + expected string + }{ + { + name: "simple command", + cmdName: "echo", + args: []string{"hello"}, + expected: "echo", + }, + { + name: "command with multiple args", + cmdName: "ls", + args: []string{"-l", "-a"}, + expected: "ls", + }, + { + name: "command with no args", + cmdName: "pwd", + args: []string{}, + expected: "pwd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New(tt.cmdName, tt.args, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + if cmd == nil { + t.Fatal("New() returned nil") + } + + if cmd.name != tt.expected { + t.Errorf("expected name %s, got %s", tt.expected, cmd.name) + } + + if len(cmd.args) != len(tt.args) { + t.Errorf("expected %d args, got %d", len(tt.args), len(cmd.args)) + } + + for i, arg := range tt.args { + if i < len(cmd.args) && cmd.args[i] != arg { + t.Errorf("expected arg[%d] %s, got %s", i, arg, cmd.args[i]) + } + } + }) + } +} + +func TestStart(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmdName string + args []string + shouldError bool + }{ + { + name: "valid command", + cmdName: "echo", + args: []string{"test"}, + shouldError: false, + }, + { + name: "invalid command", + cmdName: "nonexistentcommand12345", + args: []string{}, + shouldError: true, + }, + { + name: "valid command with args", + cmdName: "sleep", + args: []string{"0.1"}, + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New(tt.cmdName, tt.args, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + err := cmd.Start() + + if tt.shouldError { + if err == nil { + t.Error("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if cmd.Cmd == nil { + t.Error("exec.Cmd should be initialized after Start()") + } + + if cmd.Process == nil { + t.Error("Process should be set after successful Start()") + } + } + }) + } +} + +func TestWait(t *testing.T) { + t.Parallel() + + t.Run("wait for successful command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Errorf("unexpected error waiting for command: %v", err) + } + + if !cmd.Exited() { + t.Error("command should be marked as exited after Wait()") + } + }) + + t.Run("wait for failing command", func(t *testing.T) { + t.Parallel() + + // Use a command that will fail + var stdout, stderr bytes.Buffer + cmd := New("ls", []string{"/nonexistent/directory/12345"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Errorf("unexpected error from failing command: %v", err) + } + + if !cmd.Exited() { + t.Error("command should be marked as exited after Wait() even on failure") + } + }) +} + +func TestStop(t *testing.T) { + t.Parallel() + + t.Run("stop running command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("sleep", []string{"10"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + // Give the command a moment to actually start + time.Sleep(100 * time.Millisecond) + + err = cmd.Stop() + if err != nil { + t.Errorf("unexpected error stopping command: %v", err) + } + + // Wait a bit and check if process is actually killed + time.Sleep(100 * time.Millisecond) + + // Try to wait for the process - it should return quickly since it was killed + waitDone := make(chan error, 1) + go func() { + waitDone <- cmd.Wait() + }() + + select { + case <-waitDone: + // Good, Wait() returned quickly + case <-time.After(1 * time.Second): + t.Error("process was not killed properly - Wait() is still blocking") + } + }) + + t.Run("stop non-running command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + // Try to stop before starting + err := cmd.Stop() + if err != nil { + t.Errorf("stopping non-running command should not error: %v", err) + } + }) + + t.Run("stop already finished command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Fatalf("failed to wait for command: %v", err) + } + + err = cmd.Stop() + if err != nil { + t.Errorf("stopping finished command should not error: %v", err) + } + }) +} + +func TestExited(t *testing.T) { + t.Parallel() + + t.Run("not started command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + if cmd.Exited() { + t.Error("command that hasn't started should not be marked as exited") + } + }) + + t.Run("started but not finished command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("sleep", []string{"1"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + if cmd.Exited() { + t.Error("running command should not be marked as exited") + } + + // Clean up + cmd.Stop() + }) + + t.Run("finished command", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Fatalf("failed to wait for command: %v", err) + } + + if !cmd.Exited() { + t.Error("finished command should be marked as exited") + } + }) +} + +func TestMultipleStarts(t *testing.T) { + t.Parallel() + + t.Run("multiple start calls", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + // First start + err := cmd.Start() + if err != nil { + t.Fatalf("first start failed: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Fatalf("wait failed: %v", err) + } + + // Second start should work (creates new exec.Cmd) + err = cmd.Start() + if err != nil { + t.Errorf("second start failed: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Errorf("second wait failed: %v", err) + } + }) +} + +func TestMakeCommand(t *testing.T) { + t.Parallel() + + t.Run("makeCommand sets up exec.Cmd correctly", func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"hello", "world"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + // makeCommand is called internally by Start, so we test it through Start + err := cmd.Start() + if err != nil { + t.Fatalf("failed to start command: %v", err) + } + + if cmd.Cmd == nil { + t.Error("exec.Cmd should be initialized") + } + + if cmd.Stdout != &stdout { + t.Error("Stdout should be set to the provided buffer") + } + + if cmd.Stderr != &stderr { + t.Error("Stderr should be set to the provided buffer") + } + + cmd.Wait() + }) +} + +func TestCommandInterface(t *testing.T) { + t.Parallel() + + t.Run("Cmd implements Command interface", func(t *testing.T) { + t.Parallel() + + var _ Command = &Cmd{} + + var stdout, stderr bytes.Buffer + cmd := New("echo", []string{"test"}, Options{ + Stdout: &stdout, + Stderr: &stderr, + }) + + // Test that all Command interface methods work + err := cmd.Start() + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Errorf("Wait() failed: %v", err) + } + + err = cmd.Stop() + if err != nil { + t.Errorf("Stop() failed: %v", err) + } + }) +}
A default.nix
@@ -0,0 +1,22 @@ +{ + pkgs ? ( + let + inherit (builtins) fetchTree fromJSON readFile; + inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix; + in + import (fetchTree nixpkgs.locked) { + overlays = [ + (import "${fetchTree gomod2nix.locked}/overlay.nix") + ]; + } + ), + buildGoApplication ? pkgs.buildGoApplication, +}: + +buildGoApplication { + pname = "erl"; + version = "0-unstable-2025-08-30"; + pwd = ./.; + src = ./.; + modules = ./gomod2nix.toml; +}
A flake.lock
@@ -0,0 +1,255 @@ +{ + "nodes": { + "files": { + "locked": { + "lastModified": 1750263550, + "narHash": "sha256-EW/QJ8i/13GgiynBb6zOMxhLU1uEkRqmzbIDEP23yVA=", + "owner": "mightyiam", + "repo": "files", + "rev": "5f4ef1fd1f9012354a9748be093e277675d10f07", + "type": "github" + }, + "original": { + "owner": "mightyiam", + "repo": "files", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1754487366, + "narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755960406, + "narHash": "sha256-RF7j6C1TmSTK9tYWO6CdEMtg6XZaUKcvZwOCD2SICZs=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "e891a93b193fcaf2fc8012d890dc7f0befe86ec2", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_2": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1756047880, + "narHash": "sha256-JeuGh9kA1SPL70fnvpLxkIkCWpTjtoPaus3jzvdna0k=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "47d628dc3b506bd28632e47280c6b89d3496909d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756349143, + "narHash": "sha256-65oRt2D57ST5Gsa2Q4WCtpgCmKHEWt4+CQ1chWQ3Fos=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c3e5d9f86b3f5678d0d8c9f0b9f081ab1ccdbe05", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1753579242, + "narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat_2", + "gitignore": "gitignore_2", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755960406, + "narHash": "sha256-RF7j6C1TmSTK9tYWO6CdEMtg6XZaUKcvZwOCD2SICZs=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "e891a93b193fcaf2fc8012d890dc7f0befe86ec2", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "files": "files", + "flake-parts": "flake-parts", + "flake-utils": "flake-utils", + "git-hooks": "git-hooks", + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +}
A flake.nix
@@ -0,0 +1,88 @@ +{ + description = "A basic gomod2nix flake"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + gomod2nix = { + url = "github:nix-community/gomod2nix"; + inputs = { + nixpkgs.follows = "nixpkgs"; + flake-utils.follows = "flake-utils"; + }; + }; + pre-commit-hooks = { + url = "github:cachix/pre-commit-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + flake-parts.url = "github:hercules-ci/flake-parts"; + files.url = "github:mightyiam/files"; + git-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = with inputs; [ + files.flakeModules.default + git-hooks.flakeModule + ]; + systems = [ + "aarch64-linux" + "x86_64-linux" + "aarch64-darwin" + "x86_64-darwin" + ]; + perSystem = + { + config, + inputs', + pkgs, + ... + }: + { + pre-commit = { + settings = { + enabledPackages = with pkgs; [ + go + ]; + hooks = { + go-mod-tidy = { + enable = true; + name = "go-mod-tidy"; + description = "Run `go mod tidy`"; + types_or = [ + "go" + "go-mod" + ]; + entry = "${pkgs.go}/bin/go mod tidy"; + pass_filenames = false; + }; + golines.enable = true; + golangci-lint.enable = true; + gotest.enable = true; + gomod2nix = { + enable = true; + name = "gomod2nix"; + description = "Import go.mod updates to nix"; + types_or = [ "go-sum" ]; + entry = "${inputs'.gomod2nix.legacyPackages.gomod2nix}/bin/gomod2nix"; + pass_filenames = false; + }; + }; + }; + }; + packages.default = pkgs.callPackage ./. { + inherit (inputs'.gomod2nix.legacyPackages) buildGoApplication; + }; + devShells.default = pkgs.callPackage ./shell.nix { + shellHook = config.pre-commit.installationScript; + inherit (inputs'.gomod2nix.legacyPackages) mkGoEnv gomod2nix; + }; + }; + }; +}
A go.mod
@@ -0,0 +1,12 @@ +module alin.ovh/erl + +go 1.24.5 + +require github.com/fsnotify/fsnotify v1.9.0 + +require ( + github.com/fatih/color v1.18.0 + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.25.0 // indirect +)
A go.sum
@@ -0,0 +1,13 @@ +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
A gomod2nix.toml
@@ -0,0 +1,18 @@ +schema = 3 + +[mod] + [mod."github.com/fatih/color"] + version = "v1.18.0" + hash = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY=" + [mod."github.com/fsnotify/fsnotify"] + version = "v1.9.0" + hash = "sha256-WtpE1N6dpHwEvIub7Xp/CrWm0fd6PX7MKA4PV44rp2g=" + [mod."github.com/mattn/go-colorable"] + version = "v0.1.13" + hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8=" + [mod."github.com/mattn/go-isatty"] + version = "v0.0.20" + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" + [mod."golang.org/x/sys"] + version = "v0.25.0" + hash = "sha256-PXZ9EQZ7SFpcL7d3E1+KGTxziYlHEIZPfoXEbnaVD3I="
A justfile
@@ -0,0 +1,13 @@ +run *args: + go run main.go {{ args }} + +test *tests="./...": + go test {{ tests }} + +build: + go build + +nix-build: + nix build . + +ci: test build nix-build
A main.go
@@ -0,0 +1,126 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + + "alin.ovh/erl/command" + "alin.ovh/erl/state" + "alin.ovh/erl/watcher" +) + +var ( + Exec = flag.String("exec", "", "command to execute on file change") +) + +func Start(ctx context.Context, w watcher.Watcher, sm *state.StateMachine) { + var wg sync.WaitGroup + + wg.Add(1) + go func(events <-chan watcher.Event, errors <-chan error) { + defer wg.Done() + sm.SendEvent(state.Start) + for { + select { + case <-ctx.Done(): + sm.SendEvent(state.Signal) + + return + case event, ok := <-events: + if !ok { + return + } + + // skip if _only_ chmod + if event.Op == fsnotify.Chmod { + continue + } + + log.Printf("event: %s %s\n", event.Name, event.Op.String()) + if event.Op.Has(fsnotify.Create) { + stat, err := os.Stat(event.Name) + if err != nil { + log.Printf("Error getting file info: %v\n", err) + + continue + } + if stat.IsDir() { + err = w.AddRecursive(event.Name) + if err != nil { + log.Printf("Error adding directory to watcher: %v\n", err) + } + } + } + if event.Op.Has(fsnotify.Remove) { + for _, dir := range w.WatchList() { + if strings.HasPrefix(dir, event.Name) { + err := w.Remove(dir) + if err != nil { + log.Printf("Error removing directory from watcher: %v\n", err) + } + } + } + } + + sm.SendEvent(state.Restart) + + time.Sleep(100 * time.Millisecond) + + case err, ok := <-errors: + if !ok { + return + } + log.Printf("Error: %v\n", err) + } + } + }(w.Monitor()) + + err := w.AddRecursive(".") + if err != nil { + log.Fatalf("failed to add directory to watcher: %v", err) + } + + <-ctx.Done() + log.Println("shutting down") + + sm.SendEvent(state.Signal) + + wg.Wait() +} + +func main() { + log.SetFlags(log.Lmsgprefix) + flag.Parse() + + program := *Exec + if program == "" { + log.Fatal("no command provided") + } + + watcher, err := watcher.New() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + ctx, cancel := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGHUP, + syscall.SIGTERM, + ) + defer cancel() + + cmd := command.New(program, flag.Args(), command.Options{}) + sm := state.New(cmd, log.New(os.Stderr, "state: ", log.Lmsgprefix)) + Start(ctx, watcher, sm) +}
A shell.nix
@@ -0,0 +1,27 @@ +{ + pkgs ? ( + let + inherit (builtins) fetchTree fromJSON readFile; + inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix; + in + import (fetchTree nixpkgs.locked) { + overlays = [ + (import "${fetchTree gomod2nix.locked}/overlay.nix") + ]; + } + ), + mkGoEnv ? pkgs.mkGoEnv, + gomod2nix ? pkgs.gomod2nix, + shellHook ? "", +}: + +let + goEnv = mkGoEnv { pwd = ./.; }; +in +pkgs.mkShell { + inherit shellHook; + packages = [ + goEnv + gomod2nix + ]; +}
A state/state.go
@@ -0,0 +1,133 @@ +package state + +import ( + "log" + + "alin.ovh/erl/command" +) + +type State int + +const ( + NotStarted State = iota + Running + Exited +) + +func (s State) String() string { + switch s { + case NotStarted: + return "NotStarted" + case Running: + return "Running" + case Exited: + return "Exited" + default: + return "Unknown" + } +} + +type Event int + +const ( + Start Event = iota + Signal + Exit + Restart +) + +func (e Event) String() string { + switch e { + case Start: + return "Start" + case Signal: + return "Shutdown" + case Exit: + return "Stopped" + case Restart: + return "Restart" + default: + return "Unknown" + } +} + +type Action func() + +type StateMachine struct { + log *log.Logger + currentState State + transitions map[State]map[Event]State + actions map[State]map[Event]Action +} + +func New(cmd command.Command, log *log.Logger) *StateMachine { + sm := &StateMachine{ + log: log, + currentState: NotStarted, + transitions: make(map[State]map[Event]State), + actions: make(map[State]map[Event]Action), + } + + sm.transitions[NotStarted] = map[Event]State{ + Start: Running, + } + sm.transitions[Running] = map[Event]State{ + Signal: Exited, + Exit: Exited, + Restart: Running, + } + sm.transitions[Exited] = map[Event]State{ + Start: Running, + Restart: Running, + } + + wait := func(cmd command.Command) { + err := cmd.Wait() + if err != nil { + log.Printf("Error waiting for command: %v\n", err) + } + sm.SendEvent(Exit) + } + start := func() { + err := cmd.Start() + if err != nil { + log.Printf("Failed to start command: %v", err) + } + + go wait(cmd) + } + stop := func() { + err := cmd.Stop() + if err != nil { + log.Printf("Error stopping command: %v\n", err) + } + } + restart := func() { + stop() + start() + } + + sm.actions[NotStarted] = map[Event]Action{ + Start: start, + } + sm.actions[Running] = map[Event]Action{ + Signal: stop, + Restart: restart, + } + sm.actions[Exited] = map[Event]Action{ + Start: start, + Restart: start, + } + + return sm +} + +func (sm *StateMachine) SendEvent(ev Event) { + currentState := sm.currentState + if nextState, ok := sm.transitions[currentState][ev]; ok { + sm.currentState = nextState + if action, ok := sm.actions[currentState][ev]; ok { + action() + } + } +}
A state/state_test.go
@@ -0,0 +1,483 @@ +package state + +import ( + "bytes" + "fmt" + "log" + "sync" + "testing" + "time" +) + +// MockCommand implements the command.Command interface for testing +type MockCommand struct { + mu sync.Mutex + startCalled bool + stopCalled bool + waitCalled bool + startErr error + waitErr error + stopErr error + waitChan chan struct{} + started bool +} + +func NewMockCommand() *MockCommand { + return &MockCommand{ + waitChan: make(chan struct{}), + } +} + +func (m *MockCommand) Start() error { + m.mu.Lock() + defer m.mu.Unlock() + m.startCalled = true + m.started = true + + return m.startErr +} + +func (m *MockCommand) Wait() error { + m.mu.Lock() + m.waitCalled = true + m.mu.Unlock() + + // Wait for signal to simulate command completion + <-m.waitChan + + return m.waitErr +} + +func (m *MockCommand) Stop() error { + m.mu.Lock() + defer m.mu.Unlock() + m.stopCalled = true + m.started = false + // Signal Wait to return + select { + case m.waitChan <- struct{}{}: + default: + } + + return m.stopErr +} + +func (m *MockCommand) SimulateExit() { + m.mu.Lock() + m.started = false + m.mu.Unlock() + // Signal Wait to return + select { + case m.waitChan <- struct{}{}: + default: + } +} + +func (m *MockCommand) WasStartCalled() bool { + m.mu.Lock() + defer m.mu.Unlock() + + return m.startCalled +} + +func (m *MockCommand) WasStopCalled() bool { + m.mu.Lock() + defer m.mu.Unlock() + + return m.stopCalled +} + +func (m *MockCommand) WasWaitCalled() bool { + m.mu.Lock() + defer m.mu.Unlock() + + return m.waitCalled +} + +func (m *MockCommand) SetStartError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.startErr = err +} + +func (m *MockCommand) SetWaitError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.waitErr = err +} + +func (m *MockCommand) SetStopError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.stopErr = err +} + +func (m *MockCommand) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.startCalled = false + m.stopCalled = false + m.waitCalled = false + m.startErr = nil + m.waitErr = nil + m.stopErr = nil + m.started = false + m.waitChan = make(chan struct{}) +} + +func createTestStateMachine() (*StateMachine, *MockCommand, *bytes.Buffer) { + mockCmd := NewMockCommand() + var buf bytes.Buffer + logger := log.New(&buf, "", 0) + + sm := New(mockCmd, logger) + + return sm, mockCmd, &buf +} + +func TestStateString(t *testing.T) { + t.Parallel() + + tests := []struct { + state State + expected string + }{ + {NotStarted, "NotStarted"}, + {Running, "Running"}, + {Exited, "Exited"}, + {State(99), "Unknown"}, + } + + for _, tt := range tests { + if got := tt.state.String(); got != tt.expected { + t.Errorf("State.String() = %v, want %v", got, tt.expected) + } + } +} + +func TestEventString(t *testing.T) { + t.Parallel() + + tests := []struct { + event Event + expected string + }{ + {Start, "Start"}, + {Signal, "Shutdown"}, + {Exit, "Stopped"}, + {Restart, "Restart"}, + {Event(99), "Unknown"}, + } + + for _, tt := range tests { + if got := tt.event.String(); got != tt.expected { + t.Errorf("Event.String() = %v, want %v", got, tt.expected) + } + } +} + +func TestNewStateMachine(t *testing.T) { + t.Parallel() + + sm, _, _ := createTestStateMachine() + + if sm.currentState != NotStarted { + t.Errorf("Initial state should be NotStarted, got %v", sm.currentState) + } +} + +func TestStartTransition(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Send Start event + sm.SendEvent(Start) + + // Give some time for goroutine to start + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Running { + t.Errorf("State should be Running after Start event, got %v", sm.currentState) + } + + if !mockCmd.WasStartCalled() { + t.Error("Start should have been called on command") + } + + if !mockCmd.WasWaitCalled() { + t.Error("Wait should have been called on command") + } +} + +func TestSignalTransition(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Start the state machine + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + + // Reset mock to clear start calls + mockCmd.Reset() + + // Send Signal event + sm.SendEvent(Signal) + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Exited { + t.Errorf("State should be Exited after Signal event from Running, got %v", sm.currentState) + } + + if !mockCmd.WasStopCalled() { + t.Error("Stop should have been called on command") + } +} + +func TestExitTransition(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Start the state machine + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + + // Simulate command exit + mockCmd.SimulateExit() + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Exited { + t.Errorf("State should be Exited after Exit event, got %v", sm.currentState) + } +} + +func TestRestartFromRunning(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Start the state machine + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + + // Reset mock to clear initial calls + mockCmd.Reset() + + // Send Restart event + sm.SendEvent(Restart) + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Running { + t.Errorf("State should still be Running after Restart event, got %v", sm.currentState) + } + + if !mockCmd.WasStopCalled() { + t.Error("Stop should have been called during restart") + } + + if !mockCmd.WasStartCalled() { + t.Error("Start should have been called during restart") + } +} + +func TestRestartFromExited(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Start and then exit + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + mockCmd.SimulateExit() + time.Sleep(10 * time.Millisecond) + + // Reset mock + mockCmd.Reset() + + // Send Restart event from Exited state + sm.SendEvent(Restart) + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Running { + t.Errorf("State should be Running after Restart from Exited, got %v", sm.currentState) + } + + if !mockCmd.WasStartCalled() { + t.Error("Start should have been called during restart from Exited") + } +} + +func TestStartFromExited(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Start and then exit + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + mockCmd.SimulateExit() + time.Sleep(10 * time.Millisecond) + + // Reset mock + mockCmd.Reset() + + // Send Start event from Exited state + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Running { + t.Errorf("State should be Running after Start from Exited, got %v", sm.currentState) + } + + if !mockCmd.WasStartCalled() { + t.Error("Start should have been called") + } +} + +func TestInvalidTransitions(t *testing.T) { + t.Parallel() + + sm, _, buf := createTestStateMachine() + + tests := []struct { + initialState State + event Event + description string + }{ + {NotStarted, Signal, "Signal from NotStarted"}, + {NotStarted, Exit, "Exit from NotStarted"}, + {NotStarted, Restart, "Restart from NotStarted"}, + {Exited, Signal, "Signal from Exited"}, + {Exited, Exit, "Exit from Exited"}, + } + + for _, tt := range tests { + // Reset state machine to initial state + sm.currentState = tt.initialState + buf.Reset() + + sm.SendEvent(tt.event) + + if sm.currentState != tt.initialState { + t.Errorf( + "%s: state should remain %v, got %v", + tt.description, + tt.initialState, + sm.currentState, + ) + } + + logOutput := buf.String() + if logOutput != "" { + t.Errorf("%s: should not log invalid transition", tt.description) + } + } +} + +func TestCommandStartError(t *testing.T) { + t.Parallel() + + sm, mockCmd, buf := createTestStateMachine() + + // Set start to return an error + mockCmd.SetStartError(fmt.Errorf("test error")) + + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + + // Should still transition to Running state even if start fails + if sm.currentState != Running { + t.Errorf("State should be Running even if Start fails, got %v", sm.currentState) + } + + logOutput := buf.String() + if logOutput == "" { + t.Error("Should have logged start message") + } +} + +func TestCommandStopError(t *testing.T) { + t.Parallel() + + sm, mockCmd, buf := createTestStateMachine() + + // Start first + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + + // Set stop to return an error + mockCmd.SetStopError(fmt.Errorf("stop error")) + buf.Reset() + + sm.SendEvent(Signal) + time.Sleep(10 * time.Millisecond) + + if sm.currentState != Exited { + t.Errorf("State should be Exited even if Stop fails, got %v", sm.currentState) + } + + logOutput := buf.String() + if logOutput == "" { + t.Error("Should have logged stop message") + } +} + +func TestConcurrentEvents(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Send multiple events concurrently + go sm.SendEvent(Start) + go sm.SendEvent(Start) + go sm.SendEvent(Start) + + time.Sleep(50 * time.Millisecond) + + if sm.currentState != Running { + t.Errorf("State should be Running after concurrent Start events, got %v", sm.currentState) + } + + if !mockCmd.WasStartCalled() { + t.Error("Start should have been called") + } +} + +func TestCompleteLifecycle(t *testing.T) { + t.Parallel() + + sm, mockCmd, _ := createTestStateMachine() + + // Complete lifecycle: NotStarted -> Running -> Exited -> Running -> Exited + + // Start + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + if sm.currentState != Running { + t.Errorf("Expected Running, got %v", sm.currentState) + } + + // Signal to stop + sm.SendEvent(Signal) + time.Sleep(10 * time.Millisecond) + if sm.currentState != Exited { + t.Errorf("Expected Exited, got %v", sm.currentState) + } + + // Start again + mockCmd.Reset() + sm.SendEvent(Start) + time.Sleep(10 * time.Millisecond) + if sm.currentState != Running { + t.Errorf("Expected Running after restart, got %v", sm.currentState) + } + + // Exit naturally + mockCmd.SimulateExit() + time.Sleep(10 * time.Millisecond) + if sm.currentState != Exited { + t.Errorf("Expected Exited after natural exit, got %v", sm.currentState) + } +}
A watcher/watcher.go
@@ -0,0 +1,109 @@ +package watcher + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + + "github.com/fsnotify/fsnotify" +) + +type Event fsnotify.Event + +type Watcher interface { + AddRecursive(pathname string) error + Monitor() (<-chan Event, <-chan error) + WatchList() []string + Remove(pathname string) error + Close() error +} + +// FSWatcher implements the Watcher interface using fsnotify. +type FSWatcher struct { + watcher fsnotify.Watcher +} + +var IgnoredDirs = []string{ + ".git", +} + +func New() (*FSWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("failed to create watcher: %v", err) + } + + return &FSWatcher{ + watcher: *watcher, + }, nil +} + +func (w *FSWatcher) AddRecursive(pathname string) error { + stat, err := os.Stat(pathname) + if err != nil { + return fmt.Errorf("failed to stat %s: %v", pathname, err) + } + if !stat.IsDir() { + pathname = filepath.Dir(pathname) + } + + return w.addDirRecursive(pathname) +} + +func (w *FSWatcher) Remove(pathname string) error { + return w.watcher.Remove(pathname) +} + +func (w *FSWatcher) Monitor() (<-chan Event, <-chan error) { + events := make(chan Event, 1) + errors := make(chan error, 1) + + go func() { + for { + select { + case event := <-w.watcher.Events: + events <- Event(event) + case err := <-w.watcher.Errors: + errors <- err + } + } + }() + + return events, errors +} + +func (w *FSWatcher) WatchList() []string { + return w.watcher.WatchList() +} + +func (w *FSWatcher) Close() error { + return w.watcher.Close() +} + +func (w *FSWatcher) addDirRecursive(dir string) error { + err := filepath.Walk(dir, func(path string, entry fs.FileInfo, err error) error { + if err != nil { + return err + } + + if entry.IsDir() { + if slices.Contains(IgnoredDirs, entry.Name()) { + return fs.SkipDir + } + + err = w.watcher.Add(path) + if err != nil { + return fmt.Errorf("failed to add directory to watcher: %v", err) + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to walk directory: %v", err) + } + + return nil +}
A watcher/watcher_test.go
@@ -0,0 +1,249 @@ +package watcher + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/fsnotify/fsnotify" +) + +func TestNew(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer watcher.Close() + + if watcher == nil { + t.Error("New() returned nil watcher") + } +} + +func TestAddRecursive(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer watcher.Close() + + tempDir, err := os.MkdirTemp("", "watcher_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + subDir := filepath.Join(tempDir, "subdir") + err = os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + err = watcher.AddRecursive(tempDir) + if err != nil { + t.Errorf("AddRecursive() failed: %v", err) + } + + watchList := watcher.WatchList() + if len(watchList) == 0 { + t.Error("Expected directories to be added to watch list") + } + + testFile := filepath.Join(tempDir, "test.txt") + err = os.WriteFile(testFile, []byte("test"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + err = watcher.AddRecursive(testFile) + if err != nil { + t.Errorf("AddRecursive() with file failed: %v", err) + } +} + +func TestAddRecursiveIgnoredDirs(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer watcher.Close() + + tempDir, err := os.MkdirTemp("", "watcher_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create .git directory (should be ignored) + gitDir := filepath.Join(tempDir, ".git") + err = os.Mkdir(gitDir, 0755) + if err != nil { + t.Fatalf("Failed to create .git dir: %v", err) + } + + // Create regular subdirectory + subDir := filepath.Join(tempDir, "subdir") + err = os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + err = watcher.AddRecursive(tempDir) + if err != nil { + t.Errorf("AddRecursive() failed: %v", err) + } + + watchList := watcher.WatchList() + for _, path := range watchList { + if filepath.Base(path) == ".git" { + t.Errorf("Expected .git directory to be ignored, but found in watch list: %s", path) + } + } +} + +func TestAddRecursiveNonExistentPath(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer watcher.Close() + + err = watcher.AddRecursive("/non/existent/path") + if err == nil { + t.Error("Expected AddRecursive() to fail for non-existent path") + } +} + +func TestRemove(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer watcher.Close() + + tempDir, err := os.MkdirTemp("", "watcher_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + err = watcher.AddRecursive(tempDir) + if err != nil { + t.Fatalf("AddRecursive() failed: %v", err) + } + + watchList := watcher.WatchList() + if len(watchList) == 0 { + t.Fatal("Expected directory to be in watch list") + } + + err = watcher.Remove(tempDir) + if err != nil { + t.Errorf("Remove() failed: %v", err) + } + + watchList = watcher.WatchList() + for _, path := range watchList { + if path == tempDir { + t.Error("Expected directory to be removed from watch list") + } + } +} + +func TestWatchList(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer watcher.Close() + + // Initially should be empty + watchList := watcher.WatchList() + if len(watchList) != 0 { + t.Errorf("Expected empty watch list, got %d items", len(watchList)) + } + + tempDir, err := os.MkdirTemp("", "watcher_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + err = watcher.AddRecursive(tempDir) + if err != nil { + t.Fatalf("AddRecursive() failed: %v", err) + } + + watchList = watcher.WatchList() + if len(watchList) == 0 { + t.Error("Expected non-empty watch list after adding directory") + } +} + +func TestClose(t *testing.T) { + t.Parallel() + watcher, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + tempDir, err := os.MkdirTemp("", "watcher_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + err = watcher.AddRecursive(tempDir) + if err != nil { + t.Fatalf("AddRecursive() failed: %v", err) + } + + err = watcher.Close() + if err != nil { + t.Errorf("Close() failed: %v", err) + } + + err = watcher.AddRecursive(tempDir) + if err == nil { + t.Error("Expected AddRecursive() to fail after Close()") + } +} + +func TestEventTypeConversion(t *testing.T) { + t.Parallel() + fsEvent := fsnotify.Event{ + Name: "/test/path", + Op: fsnotify.Create, + } + + event := Event(fsEvent) + if event.Name != fsEvent.Name { + t.Errorf("Expected event name %s, got %s", fsEvent.Name, event.Name) + } + if event.Op != fsEvent.Op { + t.Errorf("Expected event op %v, got %v", fsEvent.Op, event.Op) + } +} + +func TestIgnoredDirs(t *testing.T) { + t.Parallel() + expectedDirs := []string{".git"} + + if len(IgnoredDirs) != len(expectedDirs) { + t.Errorf("Expected %d ignored dirs, got %d", len(expectedDirs), len(IgnoredDirs)) + } + + for _, expected := range expectedDirs { + found := slices.Contains(IgnoredDirs, expected) + if !found { + t.Errorf("Expected %s to be in IgnoredDirs", expected) + } + } +}