diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 2ca6797..ec1e41b 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,6 +2,7 @@
+
\ No newline at end of file
diff --git a/src/main/java/eu/m724/blog/BlogBuilder.java b/src/main/java/eu/m724/blog/BlogBuilder.java
index 31c2144..c5dac9b 100644
--- a/src/main/java/eu/m724/blog/BlogBuilder.java
+++ b/src/main/java/eu/m724/blog/BlogBuilder.java
@@ -6,17 +6,19 @@
package eu.m724.blog;
+import eu.m724.blog.compress.CommonsCompressor;
+import eu.m724.blog.compress.CompressException;
import eu.m724.blog.compress.FileCompressor;
import eu.m724.blog.compress.NoSuchAlgorithmException;
import eu.m724.blog.object.Article;
import eu.m724.blog.object.Feed;
import eu.m724.blog.object.RenderOptions;
import eu.m724.blog.object.Site;
+import eu.m724.blog.server.Server;
import eu.m724.blog.template.TemplateRenderer;
-import org.apache.commons.compress.compressors.CompressorException;
+import eu.m724.blog.vc.GitVersionControl;
+import eu.m724.blog.vc.VersionControl;
import org.apache.commons.io.file.PathUtils;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.lib.RepositoryBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,18 +31,18 @@ import java.util.stream.Collectors;
/**
* The {@code BlogBuilder} class facilitates building a static blog by managing templates,
- * assets, articles, and rendering output files. It uses a Git repository as the
+ * assets, articles, and rendering output files. It uses a version control (Git etc.) repository as the
* source for the blog's content and configuration.
*/
public class BlogBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(BlogBuilder.class);
- private final Git git;
+ private final Site site;
+ private final RenderOptions renderOptions;
+ private final VersionControl versionControl;
private final Path workingDirectory;
- private Site site;
private TemplateRenderer template;
- private RenderOptions renderOptions;
private Minifier minifier;
private Path templateDirectory;
@@ -50,12 +52,14 @@ public class BlogBuilder {
/**
* Constructs a {@link BlogBuilder} instance using the provided Git repository.
*
- * @param git the Git repository to be used for the blog.
+ * @param versionControl the version control repository to be used for the blog.
*/
- public BlogBuilder(Git git) {
- this.git = git;
+ private BlogBuilder(Site site, RenderOptions renderOptions, Path workingDirectory, VersionControl versionControl) {
+ this.site = site;
+ this.renderOptions = renderOptions;
+ this.versionControl = versionControl;
- this.workingDirectory = git.getRepository().getDirectory().toPath().getParent();
+ this.workingDirectory = workingDirectory;
this.templateDirectory = workingDirectory.resolve("template");
this.outputDirectory = workingDirectory.resolve("generated_out");
}
@@ -64,19 +68,19 @@ public class BlogBuilder {
* Creates a new {@link BlogBuilder} instance for the specified working directory.
* The directory is expected to be a Git repository.
*
- * @param workingDirectory the root path of the blog, which must contain a Git repository.
+ * @param directory the root path of the blog, which must be a Git repository.
* @return a {@link BlogBuilder} instance
* @throws IOException if there is an error accessing the Git repository
*/
- public static BlogBuilder fromPath(Path workingDirectory) throws IOException {
- var repository = new RepositoryBuilder()
- .setGitDir(workingDirectory.resolve(".git").toFile())
- .build();
- var git = new Git(repository);
+ public static BlogBuilder fromGitRepository(Path directory) throws IOException {
+ return BlogBuilder.fromDirectory(directory, new GitVersionControl(directory));
+ }
- //
+ public static BlogBuilder fromDirectory(Path directory, VersionControl versionControl) throws IOException {
+ var site = Site.fromConfig(directory.resolve("site.yml"));
+ var renderOptions = RenderOptions.fromConfig(directory.resolve("render.yml"));
- return new BlogBuilder(git);
+ return new BlogBuilder(site, renderOptions, directory, versionControl);
}
/**
@@ -130,14 +134,7 @@ public class BlogBuilder {
* @throws IOException if an I/O error occurs
*/
public void build() throws IOException {
- LOGGER.debug("Loading site...");
- if (site == null)
- this.site = Site.fromConfig(workingDirectory.resolve("site.yml"));
-
- if (renderOptions == null)
- this.renderOptions = RenderOptions.fromConfig(workingDirectory.resolve("render.yml"));
-
- if (this.site.minify()) {
+ if (renderOptions.minify()) {
try {
this.minifier = new Minifier();
} catch (NoClassDefFoundError e) {
@@ -193,8 +190,7 @@ public class BlogBuilder {
continue;
}
- path = articleDirectory.relativize(path);
- var article = Article.fromFile(git, path);
+ var article = Article.fromFile(versionControl, path);
if (article.draft() && !renderDrafts) {
LOGGER.info("[Article {}] Draft. Ignoring, because you didn't specify to render drafts.", path.getFileName());
@@ -202,7 +198,7 @@ public class BlogBuilder {
}
var render = template.renderArticle(article);
- var outFile = outputDirectory.resolve("article").resolve(path);
+ var outFile = outputDirectory.resolve("article").resolve(path.getFileName());
try {
Files.createDirectory(outFile.getParent());
@@ -229,8 +225,9 @@ public class BlogBuilder {
if (renderOptions.remapAssets()) {
var assetHashes = CacheBuster.copyTree(userAssetsDir, outputUserAssetsDir);
+ // This looks weird, but it's necessary if we want to use a single map
assetHashes.forEach((k, v) -> {
- fileHashes.put("assets/" + k, v); // TODO this feels like a hack
+ fileHashes.put("assets/" + k, v);
});
} else {
FileUtils.copyTree(userAssetsDir, outputUserAssetsDir);
@@ -239,8 +236,9 @@ public class BlogBuilder {
if (renderOptions.remapTemplateStatic()) {
var templateStaticHashes = CacheBuster.copyTree(templateStaticDir, outputTemplateStaticDir);
+ // This looks weird, but it's necessary if we want to use a single map
templateStaticHashes.forEach((k, v) -> {
- fileHashes.put("static/" + k, v); // TODO this feels like a hack
+ fileHashes.put("static/" + k, v);
});
} else {
FileUtils.copyTree(templateStaticDir, outputTemplateStaticDir);
@@ -259,7 +257,7 @@ public class BlogBuilder {
for (var algorithm : renderOptions.compress()) {
try {
- var compressor = new FileCompressor(algorithm);
+ var compressor = new CommonsCompressor(algorithm);
compressors.add(compressor);
} catch (NoSuchAlgorithmException e) {
LOGGER.error("No such compression algorithm, ignoring: {}", e.getAlgorithm());
@@ -276,8 +274,8 @@ public class BlogBuilder {
for (var path : tree) {
try {
compressor.compress(path);
- } catch (CompressorException e) {
- LOGGER.error("Error compressing \"{}\" to \"{}\": {}", path, compressor.getAlgorithm(), e.getMessage());
+ } catch (CompressException e) {
+ LOGGER.error("Error compressing \"{}\" to \"{}\": {}", path, compressor.getAlgorithmName(), e.getMessage());
}
}
}
diff --git a/src/main/java/eu/m724/blog/Main.java b/src/main/java/eu/m724/blog/Main.java
index ff5b471..e7a3e67 100644
--- a/src/main/java/eu/m724/blog/Main.java
+++ b/src/main/java/eu/m724/blog/Main.java
@@ -38,7 +38,10 @@ public class Main {
var start = System.nanoTime();
- var builder = BlogBuilder.fromPath(workingDirectory)
+
+ LOGGER.debug("Loading site...");
+
+ var builder = BlogBuilder.fromGitRepository(workingDirectory)
.templateDirectory(templateDirectory)
.outputDirectory(outputDirectory)
.renderDrafts(renderDrafts);
diff --git a/src/main/java/eu/m724/blog/YamlLoader.java b/src/main/java/eu/m724/blog/YamlLoader.java
new file mode 100644
index 0000000..c0c3624
--- /dev/null
+++ b/src/main/java/eu/m724/blog/YamlLoader.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog;
+
+import org.snakeyaml.engine.v2.api.Load;
+import org.snakeyaml.engine.v2.api.LoadSettings;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+public class YamlLoader {
+ private static final Load LOAD = new Load(LoadSettings.builder().build());
+
+ public static Map loadMap(Path file) throws IOException {
+ try (var inputStream = Files.newInputStream(file)) {
+ return (Map) LOAD.loadFromInputStream(inputStream);
+ }
+ }
+}
diff --git a/src/main/java/eu/m724/blog/compress/CommonsCompressor.java b/src/main/java/eu/m724/blog/compress/CommonsCompressor.java
new file mode 100644
index 0000000..582a9ae
--- /dev/null
+++ b/src/main/java/eu/m724/blog/compress/CommonsCompressor.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog.compress;
+
+import org.apache.commons.compress.compressors.CompressorException;
+import org.apache.commons.compress.compressors.CompressorStreamFactory;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+public class CommonsCompressor extends FileCompressor {
+ /**
+ * Constructs a {@link FileCompressor} instance using the provided algorithm.
+ *
+ * @param algorithm The algorithm, for valid names see {@link CompressorStreamFactory}
+ * @throws NoSuchAlgorithmException If that algorithm is unavailable or the name is invalid.
+ */
+ public CommonsCompressor(String algorithm) throws NoSuchAlgorithmException {
+ super(algorithm);
+
+ try {
+ var os = new CompressorStreamFactory().createCompressorOutputStream(algorithm, OutputStream.nullOutputStream());
+ os.close();
+ } catch (NoClassDefFoundError | CompressorException e) {
+ throw new NoSuchAlgorithmException(algorithm, e);
+ } catch (IOException e) {
+ throw new RuntimeException("Unexpected IOException closing test output stream", e);
+ }
+ }
+
+ @Override
+ public void compress(Path file) throws IOException, CompressException {
+ var destination = file.resolveSibling(file.getFileName() + "." + getAlgorithmName());
+ compress(file, destination);
+ }
+
+ @Override
+ public void compress(Path source, Path destination) throws IOException, CompressException {
+ if (Files.exists(destination))
+ throw new FileAlreadyExistsException(destination.toString());
+
+ try (
+ var outputStream = new CompressorStreamFactory()
+ .createCompressorOutputStream(getAlgorithmName(), Files.newOutputStream(destination))
+ ) {
+ Files.copy(source, outputStream);
+ } catch (CompressorException e) {
+ throw new CompressException(e);
+ }
+ }
+}
diff --git a/src/main/java/eu/m724/blog/compress/CompressException.java b/src/main/java/eu/m724/blog/compress/CompressException.java
new file mode 100644
index 0000000..f337a00
--- /dev/null
+++ b/src/main/java/eu/m724/blog/compress/CompressException.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog.compress;
+
+// TODO really runtime exception?
+public class CompressException extends RuntimeException {
+ public CompressException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/src/main/java/eu/m724/blog/compress/FileCompressor.java b/src/main/java/eu/m724/blog/compress/FileCompressor.java
index 2824b25..e6c31f5 100644
--- a/src/main/java/eu/m724/blog/compress/FileCompressor.java
+++ b/src/main/java/eu/m724/blog/compress/FileCompressor.java
@@ -6,53 +6,20 @@
package eu.m724.blog.compress;
-import org.apache.commons.compress.compressors.CompressorException;
-import org.apache.commons.compress.compressors.CompressorStreamFactory;
-
import java.io.IOException;
-import java.io.OutputStream;
import java.nio.file.*;
-public class FileCompressor {
- private final String algorithm;
+public abstract class FileCompressor {
+ private final String algorithmName;
- /**
- * Constructs a {@link FileCompressor} instance using the provided algorithm.
- *
- * @param algorithm The algorithm, for valid names see {@link CompressorStreamFactory}
- * @throws NoSuchAlgorithmException If that algorithm is unavailable or the name is invalid.
- */
- public FileCompressor(String algorithm) throws NoSuchAlgorithmException {
- this.algorithm = algorithm;
-
- try {
- var os = new CompressorStreamFactory().createCompressorOutputStream(algorithm, OutputStream.nullOutputStream());
- os.close();
- } catch (NoClassDefFoundError | CompressorException e) {
- throw new NoSuchAlgorithmException(algorithm, e);
- } catch (IOException e) {
- throw new RuntimeException("Unexpected IOException closing test output stream", e);
- }
+ protected FileCompressor(String algorithmName) {
+ this.algorithmName = algorithmName;
}
- public void compress(Path source) throws IOException, CompressorException {
- var destination = source.resolveSibling(source.getFileName() + "." + algorithm);
- compress(source, destination);
+ public String getAlgorithmName() {
+ return algorithmName;
}
- public void compress(Path source, Path destination) throws IOException, CompressorException {
- if (Files.exists(destination))
- throw new FileAlreadyExistsException(destination.toString());
-
- try (
- var outputStream = new CompressorStreamFactory()
- .createCompressorOutputStream(algorithm, Files.newOutputStream(destination))
- ) {
- Files.copy(source, outputStream);
- }
- }
-
- public String getAlgorithm() {
- return algorithm;
- }
+ abstract public void compress(Path file) throws IOException, CompressException;
+ abstract public void compress(Path source, Path destination) throws IOException, CompressException;
}
diff --git a/src/main/java/eu/m724/blog/object/Article.java b/src/main/java/eu/m724/blog/object/Article.java
index 83c8bfb..d43a75a 100644
--- a/src/main/java/eu/m724/blog/object/Article.java
+++ b/src/main/java/eu/m724/blog/object/Article.java
@@ -6,8 +6,8 @@
package eu.m724.blog.object;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.errors.GitAPIException;
+import eu.m724.blog.vc.VersionControl;
+import eu.m724.blog.vc.VersionControlException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -56,17 +56,16 @@ public record Article(
* The method extracts metadata properties, content, and versioning information
* based on the Git history of the file.
*
- * @param git the Git repository used to retrieve versioning and commit information
+ * @param versionControl the version control repository used to retrieve versioning and commit information
* @param path the relative path to the file within the "articles" directory
* @return a {@link Article} object populated with data extracted from the specified file
* @throws IOException if an error occurs during file reading
*/
- public static Article fromFile(Git git, Path path) throws IOException {
+ public static Article fromFile(VersionControl versionControl, Path path) throws IOException {
/* read properties before filtering */
var slug = path.getFileName().toString().split("\\.")[0];
- path = Path.of("articles").resolve(path);
- var lines = Files.readAllLines(git.getRepository().getDirectory().toPath().getParent().resolve(path));
+ var lines = Files.readAllLines(path);
var properties = new HashMap();
@@ -121,18 +120,18 @@ public record Article(
ZonedDateTime modifiedAt = Instant.ofEpochMilli(0).atZone(ZoneOffset.UTC);
try {
- for (var commit : git.log().addPath(path.toString()).call()) {
- createdBy = commit.getAuthorIdent().getName();
- createdAt = Instant.ofEpochSecond(commit.getCommitTime()).atZone(ZoneOffset.UTC);
+ for (var change : versionControl.getChanges(path)) {
+ createdBy = change.author();
+ createdAt = change.time();
if (revisions++ == 0) {
modifiedBy = createdBy;
modifiedAt = createdAt;
}
}
- } catch (GitAPIException e) {
+ } catch (VersionControlException e) {
draft = true;
- LOGGER.warn("[Article {}] Draft because of a Git exception: {}", slug, e.getMessage());
+ LOGGER.warn("[Article {}] Draft because of a VC exception: {}", slug, e.getMessage());
}
return new Article(slug, title, summary, draft, revisions, createdBy, createdAt, modifiedBy, modifiedAt, custom, content);
diff --git a/src/main/java/eu/m724/blog/object/RenderOptions.java b/src/main/java/eu/m724/blog/object/RenderOptions.java
index a8c8222..b64503e 100644
--- a/src/main/java/eu/m724/blog/object/RenderOptions.java
+++ b/src/main/java/eu/m724/blog/object/RenderOptions.java
@@ -6,41 +6,61 @@
package eu.m724.blog.object;
-import org.snakeyaml.engine.v2.api.Load;
-import org.snakeyaml.engine.v2.api.LoadSettings;
+import eu.m724.blog.YamlLoader;
import java.io.IOException;
-import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
+/**
+ * Options that are relevant to rendering, not about the {@link Site}.
+ *
+ * @param compress list of compression algorithms to compress output
+ * @param remapAssets whether to remap user assets, add a unique hash to the file name to bypass cache
+ * @param remapTemplateStatic whether to remap template static assets, add a unique hash to the file name to bypass cache
+ * @param minify whether to minify output
+ */
public record RenderOptions(
List compress, // TODO rename?
+ boolean remapAssets,
boolean remapTemplateStatic,
- boolean remapAssets
+
+ boolean minify
) {
+ private static final RenderOptions DEFAULT = new RenderOptions(
+ List.of("gz", "zstd"),
+ false,
+ true,
+ true
+ );
+
/**
- * Creates a {@link Site} object by reading and parsing the configuration file at the specified path.
+ * Creates a {@link Site} object by reading and parsing the configuration file at the specified file.
* The configuration file must be a JSON file.
*
- * @param path the path to the configuration file
+ * @param file the file to the configuration file
* @return a {@link Site} object initialized with the data from the configuration file
* @throws IOException if an error occurs during file reading
*/
- public static RenderOptions fromConfig(Path path) throws IOException {
- var load = new Load(LoadSettings.builder().build());
- var yaml = (Map) load.loadFromInputStream(Files.newInputStream(path));
+ public static RenderOptions fromConfig(Path file) throws IOException {
+ var yaml = YamlLoader.loadMap(file);
+
+ /* ---- */
+
+ List compress = (List) yaml.getOrDefault("compress", DEFAULT.compress());
- List compress = (List) yaml.getOrDefault("compress", new ArrayList<>());
- boolean remapTemplateStatic = (boolean) yaml.getOrDefault("remapTemplateStatic", true);
// assets are not remapped by default, because they might be hotlinked
- boolean remapAssets = (boolean) yaml.getOrDefault("remapAssets", false);
+ boolean remapAssets = (boolean) yaml.getOrDefault("remapAssets", DEFAULT.remapAssets());
+
+ boolean remapTemplateStatic = (boolean) yaml.getOrDefault("remapTemplateStatic", DEFAULT.remapTemplateStatic());
+
+ var minify = (boolean) yaml.getOrDefault("minify", DEFAULT.minify());
+
+ /* ---- */
return new RenderOptions(
- compress, remapTemplateStatic, remapAssets
+ compress, remapTemplateStatic, remapAssets, minify
);
}
}
diff --git a/src/main/java/eu/m724/blog/object/Site.java b/src/main/java/eu/m724/blog/object/Site.java
index 2b767f8..47b4e67 100644
--- a/src/main/java/eu/m724/blog/object/Site.java
+++ b/src/main/java/eu/m724/blog/object/Site.java
@@ -6,11 +6,9 @@
package eu.m724.blog.object;
-import org.snakeyaml.engine.v2.api.Load;
-import org.snakeyaml.engine.v2.api.LoadSettings;
+import eu.m724.blog.YamlLoader;
import java.io.IOException;
-import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
@@ -21,7 +19,6 @@ import java.util.Map;
* @param baseUrl the base URL of the site
* @param directory The directory that the site is installed into. Like "https://example.com/blog" turns to "/blog"
* @param templateArticles whether to parse articles with Pebble templating
- * @param minify whether to minify HTML, CSS, JS, etc.
* @param custom a map of additional custom properties
*/
public record Site(
@@ -31,28 +28,33 @@ public record Site(
String directory,
boolean templateArticles,
- boolean minify,
Map custom
) {
+ private static final Site DEFAULT = new Site(
+ "Misconfigured blog",
+ "/",
+ "/",
+ false,
+ Map.of()
+ );
+
/**
* Creates a {@link Site} object by reading and parsing the configuration file at the specified path.
* The configuration file must be a JSON file.
*
- * @param path the path to the configuration file
+ * @param file the path to the configuration file
* @return a {@link Site} object initialized with the data from the configuration file
* @throws IOException if an error occurs during file reading
*/
- public static Site fromConfig(Path path) throws IOException {
- var load = new Load(LoadSettings.builder().build());
- var yaml = (Map) load.loadFromInputStream(Files.newInputStream(path));
+ public static Site fromConfig(Path file) throws IOException {
+ var yaml = YamlLoader.loadMap(file);
- String name = (String) yaml.get("name");
- String baseUrl = (String) yaml.getOrDefault("baseUrl", "/");
- var templateArticles = (boolean) yaml.getOrDefault("templateArticles", false);
- var minify = (boolean) yaml.getOrDefault("minify", true);
+ String name = (String) yaml.getOrDefault("name", DEFAULT.name());
+ String baseUrl = (String) yaml.getOrDefault("baseUrl", DEFAULT.baseUrl());
+ var templateArticles = (boolean) yaml.getOrDefault("templateArticles", DEFAULT.templateArticles());
- String directory = "/";
+ String directory = DEFAULT.directory();
if (baseUrl != null) {
var temp = baseUrl.substring(baseUrl.indexOf(':') + 3);
var slashIndex = temp.indexOf('/');
@@ -62,7 +64,7 @@ public record Site(
}
return new Site(
- name, baseUrl, directory, templateArticles, minify, yaml
+ name, baseUrl, directory, templateArticles, yaml
);
}
}
diff --git a/src/main/java/eu/m724/blog/Server.java b/src/main/java/eu/m724/blog/server/Server.java
similarity index 99%
rename from src/main/java/eu/m724/blog/Server.java
rename to src/main/java/eu/m724/blog/server/Server.java
index 2d02176..16b55cf 100644
--- a/src/main/java/eu/m724/blog/Server.java
+++ b/src/main/java/eu/m724/blog/server/Server.java
@@ -4,7 +4,7 @@
* in the project root for the full license text.
*/
-package eu.m724.blog;
+package eu.m724.blog.server;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.SimpleFileServer;
diff --git a/src/main/java/eu/m724/blog/vc/Change.java b/src/main/java/eu/m724/blog/vc/Change.java
new file mode 100644
index 0000000..d7cb004
--- /dev/null
+++ b/src/main/java/eu/m724/blog/vc/Change.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog.vc;
+
+import java.time.ZonedDateTime;
+
+public record Change(
+ String author,
+ ZonedDateTime time
+) { }
diff --git a/src/main/java/eu/m724/blog/vc/GitVersionControl.java b/src/main/java/eu/m724/blog/vc/GitVersionControl.java
new file mode 100644
index 0000000..631803b
--- /dev/null
+++ b/src/main/java/eu/m724/blog/vc/GitVersionControl.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog.vc;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.RepositoryBuilder;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+
+public class GitVersionControl implements VersionControl {
+ private final Git git;
+ private final Path directory;
+
+ public GitVersionControl(Path directory) throws IOException {
+ this.directory = directory;
+
+ var repository = new RepositoryBuilder()
+ .setGitDir(directory.resolve(".git").toFile())
+ .build();
+
+ this.git = new Git(repository);
+ }
+
+ @Override
+ public List getChanges(Path path) throws VersionControlException {
+ path = path.normalize();
+ if (path.startsWith(directory)) {
+ path = directory.relativize(path);
+ }
+
+ var changes = new ArrayList();
+
+ try {
+ for (var commit : git.log().addPath(path.toString()).call()) {
+ var author = commit.getAuthorIdent().getName();
+ var time = Instant.ofEpochSecond(commit.getCommitTime()).atZone(ZoneOffset.UTC);
+
+ changes.add(new Change(author, time));
+ }
+ } catch (GitAPIException e) {
+ throw new VersionControlException(e);
+ }
+
+ return changes;
+ }
+}
diff --git a/src/main/java/eu/m724/blog/vc/VersionControl.java b/src/main/java/eu/m724/blog/vc/VersionControl.java
new file mode 100644
index 0000000..31c3bdd
--- /dev/null
+++ b/src/main/java/eu/m724/blog/vc/VersionControl.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog.vc;
+
+import java.nio.file.Path;
+import java.util.List;
+
+public interface VersionControl {
+ List getChanges(Path path) throws VersionControlException;
+}
+
diff --git a/src/main/java/eu/m724/blog/vc/VersionControlException.java b/src/main/java/eu/m724/blog/vc/VersionControlException.java
new file mode 100644
index 0000000..e3a251d
--- /dev/null
+++ b/src/main/java/eu/m724/blog/vc/VersionControlException.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 blog-software-java developers
+ * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
+ * in the project root for the full license text.
+ */
+
+package eu.m724.blog.vc;
+
+// TODO runtime exception really?
+public class VersionControlException extends RuntimeException {
+ public VersionControlException(Throwable cause) {
+ // TODO maybe I need to raise message here too
+ super(cause);
+ }
+}