Utilities for File Handling

GitLab Docs Pipeline Coverage License

A Java module providing utilities for working with files, paths, and directories. It includes file and directory watching with full symlink chain support, scoped temporary folders with guaranteed cleanup, and common path and file operations that work around JVM caching limitations.

See morimekta.net/utils for procedures on releases.

Getting Started

To add to maven: Add this to the dependencies section in pom.xml:

<dependency>
    <groupId>net.morimekta.utils</groupId>
    <artifactId>file</artifactId>
    <version>4.1.2</version>
</dependency>

To add to gradle: Add this to the dependencies block in build.gradle:

implementation 'net.morimekta.utils:file:4.1.2'

File Watching

Watch for updates on files and directories. The watchers are designed to handle file structures like Kubernetes ConfigMap volumes, where content is updated through intermediate symlink changes rather than direct file writes.

The module provides two watcher classes and a listener interface:

  • DirWatcher is a wrapper around Java's native WatchService that provides a listener-based API for monitoring directories. Each listener is registered for specific directories and only receives events for the directories it was registered on. Listeners can optionally be added as weak references, allowing them to be garbage collected when no other reference to the listener exists.
  • FileWatcher builds on top of DirWatcher to monitor individual files. When the watched file is a symbolic link, the watcher resolves the full chain of symlink intermediaries and monitors all of them. If any link in the chain changes, the watcher normalizes it into a single file event on the originally requested file path.
import net.morimekta.file.DirWatcher;
import net.morimekta.file.FileEventListener;
import net.morimekta.file.FileEvent;
import net.morimekta.file.FileWatcher;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

class MyWatcher implements FileEventListener {
  void onFileEvent(Path file, FileEvent event) {
    System.out.println(file + " " + event);
  }

  void main(String... args) throws Exception {
    var dirWatcher = new DirWatcher();
    var fileWatcher = new FileWatcher(dirWatcher);
    var path = Paths.get(args[0]);
    if (Files.isDirectory(path)) {
      dirWatcher.addWatcher(path, this);
    } else {
      fileWatcher.addWatcher(path, this);
    }
    Thread.sleep(Long.parseLong(args[1]));
    System.exit(0);
  }
}

Temporary Asset Folder

A scoped temporary folder that is deleted immediately when closed. This is useful when an operation needs a set of temporary files whose lifecycle should be tightly controlled, rather than left to JVM shutdown hooks.

In resource-constrained environments this distinction matters. For example, Kubernetes pods often back their temporary storage with an in-memory filesystem (emptyDir with medium: Memory), so accumulated temporary files directly consume memory. Using TemporaryAssetFolder in a try-with-resources block ensures cleanup happens as soon as the operation finishes.

import net.morimekta.file.TemporaryAssetFolder;

import java.io.IOException;

class FileHandler {
  public Image reEncode(Image image, Format format) throws IOException {
    try (var tmp = new TemporaryAssetFolder()) {
      var tmpSource = tmp.resolveFile("source." + image.format.suffix);
      image.writeTo(tmpSource);
      var tmpTarget = tmp.resolveFile("target." + format.suffix);
      var result = new SubProcessRunner().exec(
              "ffmpeg", tmpSource.toString(), tmpTarget.toString());
      if (result != 0) {
        throw new IOException("failed to write " + tmpTarget);
      }
      return Image.readFrom(tmpTarget);
    }
    // all content of 'tmp' is deleted here.
  }
}

Utilities

Utility classes for common file and path operations, used both internally by the watchers and available for general use.

FileUtil

readCanonicalPath(path): Resolves a path to its absolute canonical form by reading symlink targets directly from the file system. Unlike File.getCanonicalPath() or Path.toRealPath(), this method never uses cached symlink targets. The JVM aggressively caches resolved paths, which effectively makes symlinks static within a running process. This method bypasses that cache and always reads the current symlink target, making it essential for long-running processes that need to detect symlink changes.

replaceSymbolicLink(link, newTarget): Atomically replaces an existing symbolic link with a new target. This avoids the delete-then-create pattern, which would produce two separate file events. Instead, it creates a temporary link and atomically moves it into place, generating only a single event.

list(path, recursive): Lists all files in a directory. When recursive is true, it enters sub-directories and lists their contents instead of listing the sub-directories themselves. Symbolic links to directories are listed as entries but not followed.

deleteRecursively(path): Deletes a file or directory and all of its contents recursively. Symbolic links to directories are deleted without following them.

PathUtil

getFileBaseName(path): Returns the file name without its suffix. Leading dots are considered part of the base name (e.g. .gitignore has base name .gitignore). Consecutive dots before the suffix are also kept as part of the base name (e.g. test..file has base name test..file).

getFileSuffix(path): Returns the file suffix, which is the part after the last dot, provided the dot is preceded by a non-dot character. Returns an empty string if there is no suffix. For example, image.png has suffix png, while .gitignore has no suffix.

getFileName(path): Returns the file name component of a path. This is the part after the last path separator. Returns an empty string for root paths.