From 825060ab2b46f11e957597cb1ad36f4f813be70c Mon Sep 17 00:00:00 2001 From: Yannik Hampe Date: Mon, 2 Mar 2015 15:17:22 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 36 +++++++ pom.xml | 51 ++++++++++ .../filewatch/DelayCallMerger.java | 33 +++++++ .../filewatch/OnFileChangeRunner.java | 57 +++++++++++ .../de/softwertiger/filewatch/StreamUtil.java | 43 +++++++++ .../IllegalCommandLineOptionsException.java | 4 + .../de/softwertiger/filewatch/cli/Main.java | 43 +++++++++ .../softwertiger/filewatch/cli/Options.java | 54 +++++++++++ .../filewatch/OnFileChangeRunnerTest.java | 95 +++++++++++++++++++ .../filewatch/StreamUtilTest.java | 53 +++++++++++ .../filewatch/cli/OptionsTest.java | 59 ++++++++++++ 12 files changed, 530 insertions(+) create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/de/softwertiger/filewatch/DelayCallMerger.java create mode 100644 src/main/java/de/softwertiger/filewatch/OnFileChangeRunner.java create mode 100644 src/main/java/de/softwertiger/filewatch/StreamUtil.java create mode 100644 src/main/java/de/softwertiger/filewatch/cli/IllegalCommandLineOptionsException.java create mode 100644 src/main/java/de/softwertiger/filewatch/cli/Main.java create mode 100644 src/main/java/de/softwertiger/filewatch/cli/Options.java create mode 100644 src/test/java/de/softwertiger/filewatch/OnFileChangeRunnerTest.java create mode 100644 src/test/java/de/softwertiger/filewatch/StreamUtilTest.java create mode 100644 src/test/java/de/softwertiger/filewatch/cli/OptionsTest.java diff --git a/.gitignore b/.gitignore index 088836b..a11a56e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next release.properties +*.iml +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef50107 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# java-file-change-watcher + +The JavaFileChangeWatcher is a small programm (Jar File: ~10Kb) which watches a single file for changes and executes +a command if the file was created or changed. + +The program aims to be similar to [inotifywatch](https://github.com/rvoicilas/inotify-tools) but platform independent +so that it works on Windows as well. + +# Features + + - ...uses the Watcher-API introduced in Java 7 which should make it get along with low system resources. + - ...after a file change was detected waits another second before executing. If another change is detected during that + second start to wait another second to reduce command executions if many alterations happen to a file in a small time. + +# Usage + + java -jar filewatch.jar [-v] + +Remember that "commandToExecute" is not executed in a shell, so you cannot do any fancy stuff like string expansion (*). +I suggest creating a script file (.bat on Windows, .sh on Linux) with your fancy things and just let this program call +that script. + +# Alternatives + +The following alternatives I encountered: + + - [Using the PowerShell on Windows](http://blogs.technet.com/b/heyscriptingguy/archive/2004/10/11/how-can-i-automatically-run-a-script-any-time-a-file-is-added-to-a-folder.aspx) (I could not get that one to work, but I never worked with the MS Powershell before so it's probably just me) + - [Belvedere](http://ca.lifehacker.com/341950/belvedere-automates-your-self+cleaning-pc) (Is Windows only and I wanted something that works on Linux as well for testing. Additionally it is not lightweight) + - [when_changed](https://github.com/benblamey/when_changed) (Similar to this one, but written in C#. Maybe it works with Mono on Linux, I did not test) + +If you know another tool which you think may be helpful for others to find, feel free to create a pull request. + +# Contributing + +Feel free to create a pull request if you think something important is missing. Please submit tests with your code and +remember that this tool is meant to be lightweight, so no huge add ons. \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3d988af --- /dev/null +++ b/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + de.softwertiger.filewatch + filewatch + 1.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.1 + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + de.softwertiger.filewatch.cli.Main + + + + + + + + + + org.testng + testng + 6.8.8 + test + + + org.mockito + mockito-all + 1.9.5 + test + + + \ No newline at end of file diff --git a/src/main/java/de/softwertiger/filewatch/DelayCallMerger.java b/src/main/java/de/softwertiger/filewatch/DelayCallMerger.java new file mode 100644 index 0000000..676cc57 --- /dev/null +++ b/src/main/java/de/softwertiger/filewatch/DelayCallMerger.java @@ -0,0 +1,33 @@ +package de.softwertiger.filewatch; + +import java.util.concurrent.atomic.AtomicInteger; + +public class DelayCallMerger implements Runnable { + private final Runnable delegate; + private volatile AtomicInteger runCounter = new AtomicInteger(0); + + public DelayCallMerger(final Runnable delegate) { + this.delegate = delegate; + } + + @Override + public void run() { + int id = runCounter.incrementAndGet(); + new Thread() { + @Override + public void run() { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + return; + } + synchronized (DelayCallMerger.this) { + if (id == runCounter.get()) { + delegate.run(); + } + } + } + }.start(); + } +} diff --git a/src/main/java/de/softwertiger/filewatch/OnFileChangeRunner.java b/src/main/java/de/softwertiger/filewatch/OnFileChangeRunner.java new file mode 100644 index 0000000..f746ead --- /dev/null +++ b/src/main/java/de/softwertiger/filewatch/OnFileChangeRunner.java @@ -0,0 +1,57 @@ +package de.softwertiger.filewatch; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; + +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; + +public class OnFileChangeRunner { + private final Path watchDir; + private final Path watchFile; + private final WatchService watchService; + + private OnFileChangeRunner(final Path watchFile) throws IOException { + this.watchFile = watchFile; + watchDir = watchFile.getParent(); + watchService = FileSystems.getDefault().newWatchService(); + watchDir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY); + } + + public static OnFileChangeRunner registerForFile(final Path watchFile) throws IOException { + return new OnFileChangeRunner(watchFile); + } + + public void runOnFileChange(final Runnable runnable) { + try { + tryRunOnFileChange(runnable); + } catch (IOException e) { + throw new Error(e); + } + } + + private void tryRunOnFileChange(final Runnable runnable) throws IOException { + try { + final WatchKey take = watchService.take(); + while (!Thread.interrupted()) { + for (final WatchEvent watchEvent : take.pollEvents()) { + final WatchEvent ev = cast(watchEvent); + if (watchFile.equals(watchDir.resolve(ev.context()))) { + runnable.run(); + } + } + } + } catch (InterruptedException e) { + // Ignore + } + } + + @SuppressWarnings("unchecked") + private static WatchEvent cast(WatchEvent event) { + return (WatchEvent) event; + } +} diff --git a/src/main/java/de/softwertiger/filewatch/StreamUtil.java b/src/main/java/de/softwertiger/filewatch/StreamUtil.java new file mode 100644 index 0000000..d84bdf0 --- /dev/null +++ b/src/main/java/de/softwertiger/filewatch/StreamUtil.java @@ -0,0 +1,43 @@ +package de.softwertiger.filewatch; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public class StreamUtil { + private final ThreadFactory threadFactory; + private final int bufferSize; + + public StreamUtil() { + this(new ThreadFactory() { + private final AtomicInteger threadNumber = new AtomicInteger(1); + @Override + public Thread newThread(final Runnable r) { + return new Thread(r, "stream-util-" + threadNumber); + } + }, 8192); + } + + StreamUtil(final ThreadFactory threadFactory, final int bufferSize) { + this.threadFactory = threadFactory; + this.bufferSize = bufferSize; + } + + public Thread forwardStream(final InputStream in, final PrintStream out) { + Thread thread = threadFactory.newThread(() -> { + byte[] buffer = new byte[bufferSize]; + int read; + try { + while((read = in.read(buffer)) > -1) { + out.write(buffer, 0, read); + } + } catch (IOException e) { + e.printStackTrace(); + } + }); + thread.start(); + return thread; + } +} diff --git a/src/main/java/de/softwertiger/filewatch/cli/IllegalCommandLineOptionsException.java b/src/main/java/de/softwertiger/filewatch/cli/IllegalCommandLineOptionsException.java new file mode 100644 index 0000000..56a13bd --- /dev/null +++ b/src/main/java/de/softwertiger/filewatch/cli/IllegalCommandLineOptionsException.java @@ -0,0 +1,4 @@ +package de.softwertiger.filewatch.cli; + +public class IllegalCommandLineOptionsException extends RuntimeException { +} diff --git a/src/main/java/de/softwertiger/filewatch/cli/Main.java b/src/main/java/de/softwertiger/filewatch/cli/Main.java new file mode 100644 index 0000000..68e79f2 --- /dev/null +++ b/src/main/java/de/softwertiger/filewatch/cli/Main.java @@ -0,0 +1,43 @@ +package de.softwertiger.filewatch.cli; + +import de.softwertiger.filewatch.DelayCallMerger; +import de.softwertiger.filewatch.OnFileChangeRunner; +import de.softwertiger.filewatch.StreamUtil; + +import java.io.IOException; + +public class Main { + private static final StreamUtil streamUtil = new StreamUtil(); + private final Options options; + + public Main(final Options options) throws IOException { + this.options = options; + if (options.isVerbose()) { + System.out.println("Watching <" + options.getFileToWatch() + "> for changes"); + } + OnFileChangeRunner + .registerForFile(options.getFileToWatch()) + .runOnFileChange(new DelayCallMerger(this::tryRunCommand)); + } + + public static void main(String[] args) throws Exception { + try { + new Main(Options.parseCommandLineOptions(args)); + } catch (IllegalCommandLineOptionsException e) { + System.out.println("Usage: fileWatcher [-v] "); + } + } + + private void tryRunCommand() { + if (options.isVerbose()) { + System.out.println("File has changed. Executing..."); + } + try { + final Process process = Runtime.getRuntime().exec(options.getCommand()); + streamUtil.forwardStream(process.getInputStream(), System.out); + streamUtil.forwardStream(process.getErrorStream(), System.err); + } catch (IOException e) { + throw new Error(e); + } + } +} diff --git a/src/main/java/de/softwertiger/filewatch/cli/Options.java b/src/main/java/de/softwertiger/filewatch/cli/Options.java new file mode 100644 index 0000000..a1d6269 --- /dev/null +++ b/src/main/java/de/softwertiger/filewatch/cli/Options.java @@ -0,0 +1,54 @@ +package de.softwertiger.filewatch.cli; + +import java.nio.file.Path; +import java.nio.file.Paths; + +public class Options { + private final boolean verbose; + private final Path fileToWatch; + private final String command; + + private Options(final boolean verbose, final Path fileToWatch, final String command) { + if (fileToWatch == null || command == null) { + throw new IllegalCommandLineOptionsException(); + } + this.verbose = verbose; + this.fileToWatch = fileToWatch; + this.command = command; + } + + public static Options parseCommandLineOptions(final String[] args) { + boolean verbose = false; + Path fileToWatch = null; + String command = null; + int unnamedPos = 0; + for (final String arg : args) { + if (arg.equals("-v")) { + verbose = true; + } else { + if (unnamedPos == 0) { + fileToWatch = Paths.get(System.getProperty("user.dir")).resolve(arg).normalize(); + } else if (unnamedPos == 1) { + command = arg; + } else { + throw new IllegalCommandLineOptionsException(); // too many args + } + ++unnamedPos; + } + } + + return new Options(verbose, fileToWatch, command); + } + + public String getCommand() { + return command; + } + + public Path getFileToWatch() { + return fileToWatch; + } + + public boolean isVerbose() { + return verbose; + } +} diff --git a/src/test/java/de/softwertiger/filewatch/OnFileChangeRunnerTest.java b/src/test/java/de/softwertiger/filewatch/OnFileChangeRunnerTest.java new file mode 100644 index 0000000..8fbe62b --- /dev/null +++ b/src/test/java/de/softwertiger/filewatch/OnFileChangeRunnerTest.java @@ -0,0 +1,95 @@ +package de.softwertiger.filewatch; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class OnFileChangeRunnerTest { + private Path dir; + private Path fileToWatch; + + @BeforeMethod + public void setUp() throws Exception { + dir = Files.createTempDirectory("onFileChangeTest"); + fileToWatch = dir.resolve("fileToWatch"); + } + + @AfterMethod + public void tearDown() throws Exception { + Files.walkFileTree(dir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + @Test(timeOut = 10000) + public void runOnFileChange_firesOnFileCreation() throws Exception { + // setup + final CountDownLatch runnableExecuted = watch(); + + // execution + Files.write(fileToWatch, new byte[0]); + + // evaluation + runnableExecuted.await(); // OK if terminates + } + + @Test(timeOut = 10000) + public void runOnFileChange_firesOnFileChange() throws Exception { + // setup + Files.write(fileToWatch, new byte[0]); + final CountDownLatch runnableExecuted = watch(); + + // execution + Files.write(fileToWatch, new byte[]{1}); + + // evaluation + runnableExecuted.await(); // OK if terminates + } + + @Test(timeOut = 10000) + public void runOnFileChange_firesAgainOnSecondFileChange() throws Exception { + // setup + Files.write(fileToWatch, new byte[0]); + final CountDownLatch runnableExecuted = watch(); + // First change: + Files.write(fileToWatch, new byte[]{1}); + runnableExecuted.await(); // OK if terminates + + // execution (second change) + Files.write(fileToWatch, new byte[]{2}); + + // evaluation + runnableExecuted.await(); // OK if terminates + } + + private CountDownLatch watch() throws IOException { + final CountDownLatch runnableExecuted = new CountDownLatch(1); + final OnFileChangeRunner onChangeRunner = OnFileChangeRunner.registerForFile(fileToWatch); + final ExecutorService executorService = Executors.newSingleThreadExecutor(); + executorService.submit(() -> onChangeRunner.runOnFileChange(() -> { + runnableExecuted.countDown(); + executorService.shutdownNow(); + })); + return runnableExecuted; + } +} \ No newline at end of file diff --git a/src/test/java/de/softwertiger/filewatch/StreamUtilTest.java b/src/test/java/de/softwertiger/filewatch/StreamUtilTest.java new file mode 100644 index 0000000..49ddcc7 --- /dev/null +++ b/src/test/java/de/softwertiger/filewatch/StreamUtilTest.java @@ -0,0 +1,53 @@ +package de.softwertiger.filewatch; + +import org.testng.annotations.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.concurrent.ThreadFactory; + +import static org.testng.Assert.assertEquals; + +public class StreamUtilTest { + private static final int BUFFER_SIZE = 16; + private static final ThreadFactory NO_THREAD_FACTORY = runnable -> new Thread() { + @Override + public void start() { + runnable.run(); + } + }; + + private final StreamUtil streamUtil = new StreamUtil(NO_THREAD_FACTORY, BUFFER_SIZE); + + @Test + public void forwardStream_copiesStreamToTarget() throws Exception { + // setup + final byte[] data = new byte[]{1, 2, 3}; + final ByteArrayInputStream in = new ByteArrayInputStream(data); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // execution + streamUtil.forwardStream(in, new PrintStream(out)); + + // evaluation + assertEquals(out.toByteArray(), data); + } + + @Test + public void forwardStream_forwardsStreamLargerThanBuffer() throws Exception { + // setup + final byte[] data = new byte[BUFFER_SIZE * 2]; + for(byte i = 0; i < BUFFER_SIZE * 2; ++i) { + data[i] = i; + } + final ByteArrayInputStream in = new ByteArrayInputStream(data); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // execution + streamUtil.forwardStream(in, new PrintStream(out)); + + // evaluation + assertEquals(out.toByteArray(), data); + } +} \ No newline at end of file diff --git a/src/test/java/de/softwertiger/filewatch/cli/OptionsTest.java b/src/test/java/de/softwertiger/filewatch/cli/OptionsTest.java new file mode 100644 index 0000000..9c70b7d --- /dev/null +++ b/src/test/java/de/softwertiger/filewatch/cli/OptionsTest.java @@ -0,0 +1,59 @@ +package de.softwertiger.filewatch.cli; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.testng.Assert.*; + +public class OptionsTest { + private static final Path CWD = Paths.get(System.getProperty("user.dir")); + private static final Path WATCH_FILE = Paths.get("watchFile"); + private static final Path WATCH_FILE_ABSOLUTE = CWD.resolve(WATCH_FILE); + private static final String COMMAND = "command"; + + @Test(expectedExceptions = IllegalCommandLineOptionsException.class, dataProvider = "provide_invalidArguments") + public void parseCommandLineOptions_throwsException_ifArgumentsAreInvalid(@SuppressWarnings("UnusedParameters") + final String description, + final String args[]) throws Exception { + + // execution + Options.parseCommandLineOptions(args); + + // evaluation performed by expected exception + } + + @DataProvider + public Object[][] provide_invalidArguments() { + return new Object[][]{ + {"too few: zero args", new String[0]}, + {"too few: one arg", new String[]{"arg1"}}, + {"too few: two args, but one is -v", new String[]{"-v", "arg1"}}, + {"too many args", new String[]{"arg1", "arg2", "one arg too much"}}, + }; + } + + @Test + public void parseCommandLineOptions_parsesMinimalAsExpected() throws Exception { + // execution + final Options actual = Options.parseCommandLineOptions(new String[]{WATCH_FILE.toString(), COMMAND}); + + // evaluation + assertEquals(actual.getCommand(), COMMAND); + assertEquals(actual.getFileToWatch(), WATCH_FILE_ABSOLUTE); + assertEquals(actual.isVerbose(), false); + } + + @Test + public void parseCommandLineOptions_parsesAllArgsAsExpected() throws Exception { + // execution + final Options actual = Options.parseCommandLineOptions(new String[]{"-v", WATCH_FILE.toString(), COMMAND}); + + // evaluation + assertEquals(actual.getCommand(), COMMAND); + assertEquals(actual.getFileToWatch(), WATCH_FILE_ABSOLUTE); + assertEquals(actual.isVerbose(), true); + } +} \ No newline at end of file