VcsState.java
package network.ike.plugin.ws.vcs;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import java.util.Properties;
import java.io.StringReader;
/**
* The VCS state file ({@code .ike/vcs-state}) records the last VCS action
* performed in a repository. Written by git hooks and plugin goals,
* delivered between machines by Syncthing.
*
* <p>Format is a plain properties file — trivial to parse in both bash
* (grep/cut) and Java (Properties.load).
*
* @param timestamp UTC timestamp of the action
* @param machine short hostname of the machine that performed the action
* @param branch the branch name at the time of the action
* @param sha the 8-character short SHA at the time of the action
* @param action the action performed (e.g., {@link Action#COMMIT})
*/
public record VcsState(
String timestamp,
String machine,
String branch,
String sha,
Action action
) {
/**
* Actions written to the state file by hooks and plugin goals.
*
* <p>Each constant maps to a lowercase label written to the
* {@code .ike/vcs-state} properties file. Parsing from the file
* is case-insensitive via {@link #fromString(String)}.
*/
public enum Action {
COMMIT("commit"),
PUSH("push"),
FEATURE_START("feature-start"),
FEATURE_FINISH("feature-finish"),
SWITCH("switch"),
RELEASE("release"),
CHECKPOINT("checkpoint");
private final String label;
Action(String label) {
this.label = label;
}
/**
* The lowercase label written to the state file.
*
* @return the action label (e.g., "commit", "feature-start")
*/
public String label() {
return label;
}
/**
* Parse an action string case-insensitively.
*
* <p>Normalizes the input (uppercase, hyphens to underscores) and
* delegates to {@link #valueOf(String)}.
*
* @param value the action string from the state file (e.g., "commit", "feature-start")
* @return the matching Action
* @throws IllegalArgumentException if no match is found
*/
public static Action fromString(String value) {
return valueOf(value.toUpperCase().replace('-', '_'));
}
}
private static final String STATE_FILE = ".ike/vcs-state";
private static final DateTimeFormatter UTC_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC);
/**
* Read the VCS state file from the given directory.
*
* @param dir the repository root directory
* @return the state if the file exists and is readable, empty otherwise
*/
public static Optional<VcsState> readFrom(Path dir) {
Path stateFile = dir.resolve(STATE_FILE);
if (!Files.exists(stateFile)) {
return Optional.empty();
}
try {
String content = Files.readString(stateFile, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content));
String timestamp = props.getProperty("timestamp", "");
String machine = props.getProperty("machine", "");
String branch = props.getProperty("branch", "");
String sha = props.getProperty("sha", "");
String actionStr = props.getProperty("action", "");
if (sha.isEmpty()) {
return Optional.empty();
}
Action action;
try {
action = Action.fromString(actionStr);
} catch (IllegalArgumentException e) {
// Unknown action in state file — treat as unreadable
return Optional.empty();
}
return Optional.of(new VcsState(timestamp, machine, branch, sha, action));
} catch (IOException e) {
return Optional.empty();
}
}
/**
* Write the VCS state file to the given directory.
*
* @param dir the repository root directory (must contain {@code .ike/})
* @param state the state to write
* @throws IOException if the file cannot be written
*/
public static void writeTo(Path dir, VcsState state) throws IOException {
Path stateFile = dir.resolve(STATE_FILE);
Files.createDirectories(stateFile.getParent());
String content = "timestamp=" + state.timestamp() + "\n"
+ "machine=" + state.machine() + "\n"
+ "branch=" + state.branch() + "\n"
+ "sha=" + state.sha() + "\n"
+ "action=" + state.action().label() + "\n";
Files.writeString(stateFile, content, StandardCharsets.UTF_8);
}
/**
* Create a VcsState with the current timestamp and local hostname.
*
* @param branch the current branch name
* @param sha the current HEAD SHA (8-character short form)
* @param action the action being performed
* @return a new VcsState
*/
public static VcsState create(String branch, String sha, Action action) {
String timestamp = UTC_FORMAT.format(Instant.now());
String machine = hostname();
return new VcsState(timestamp, machine, branch, sha, action);
}
/**
* Check whether the {@code .ike/} directory exists in the given path,
* indicating the repo is IKE-managed.
*
* @param dir the repository root directory
* @return true if the repo has an {@code .ike/} directory
*/
public static boolean isIkeManaged(Path dir) {
return Files.isDirectory(dir.resolve(".ike"));
}
private static String hostname() {
String host = System.getenv("HOSTNAME");
if (host == null || host.isEmpty()) {
try {
host = java.net.InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
host = "unknown";
}
}
// Strip domain — equivalent to ${HOSTNAME%%.*} in bash
int dot = host.indexOf('.');
return dot > 0 ? host.substring(0, dot) : host;
}
}